From 35aba4128cd06306c40eace546161036c59f9271 Mon Sep 17 00:00:00 2001 From: Joshua Primas Date: Fri, 20 Feb 2026 15:24:48 -0800 Subject: [PATCH 01/68] Adding the LemonSlice transport integration --- README.md | 2 +- env.example | 4 + .../foundational/55-lemonslice-transport.py | 117 +++ examples/foundational/README.md | 1 + pyproject.toml | 1 + src/pipecat/transports/lemonslice/__init__.py | 0 .../transports/lemonslice/transport.py | 799 ++++++++++++++++++ src/pipecat/transports/lemonslice/utils.py | 108 +++ uv.lock | 48 +- 9 files changed, 1055 insertions(+), 25 deletions(-) create mode 100644 examples/foundational/55-lemonslice-transport.py create mode 100644 src/pipecat/transports/lemonslice/__init__.py create mode 100644 src/pipecat/transports/lemonslice/transport.py create mode 100644 src/pipecat/transports/lemonslice/utils.py diff --git a/README.md b/README.md index 6d6a56612..38a6aa8b3 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ Catch new features, interviews, and how-tos on our [Pipecat TV](https://www.yout | Speech-to-Speech | [AWS Nova Sonic](https://docs.pipecat.ai/server/services/s2s/aws), [Gemini Multimodal Live](https://docs.pipecat.ai/server/services/s2s/gemini), [Grok Voice Agent](https://docs.pipecat.ai/server/services/s2s/grok), [OpenAI Realtime](https://docs.pipecat.ai/server/services/s2s/openai), [Ultravox](https://docs.pipecat.ai/server/services/s2s/ultravox), | | Transport | [Daily (WebRTC)](https://docs.pipecat.ai/server/services/transport/daily), [FastAPI Websocket](https://docs.pipecat.ai/server/services/transport/fastapi-websocket), [SmallWebRTCTransport](https://docs.pipecat.ai/server/services/transport/small-webrtc), [WebSocket Server](https://docs.pipecat.ai/server/services/transport/websocket-server), Local | | Serializers | [Exotel](https://docs.pipecat.ai/server/utilities/serializers/exotel), [Plivo](https://docs.pipecat.ai/server/utilities/serializers/plivo), [Twilio](https://docs.pipecat.ai/server/utilities/serializers/twilio), [Telnyx](https://docs.pipecat.ai/server/utilities/serializers/telnyx), [Vonage](https://docs.pipecat.ai/server/utilities/serializers/vonage) | -| Video | [HeyGen](https://docs.pipecat.ai/server/services/video/heygen), [Tavus](https://docs.pipecat.ai/server/services/video/tavus), [Simli](https://docs.pipecat.ai/server/services/video/simli) | +| Video | [HeyGen](https://docs.pipecat.ai/server/services/video/heygen), [LemonSlice](https://lemonslice.com/docs/self-managed/overview), [Tavus](https://docs.pipecat.ai/server/services/video/tavus), [Simli](https://docs.pipecat.ai/server/services/video/simli) | | Memory | [mem0](https://docs.pipecat.ai/server/services/memory/mem0) | | Vision & Image | [fal](https://docs.pipecat.ai/server/services/image-generation/fal), [Google Imagen](https://docs.pipecat.ai/server/services/image-generation/google-imagen), [Moondream](https://docs.pipecat.ai/server/services/vision/moondream) | | Audio Processing | [Silero VAD](https://docs.pipecat.ai/server/utilities/audio/silero-vad-analyzer), [Krisp](https://docs.pipecat.ai/server/utilities/audio/krisp-filter), [Koala](https://docs.pipecat.ai/server/utilities/audio/koala-filter), [ai-coustics](https://docs.pipecat.ai/server/utilities/audio/aic-filter) | diff --git a/env.example b/env.example index bc14ea0bf..da52b84dc 100644 --- a/env.example +++ b/env.example @@ -107,6 +107,10 @@ KRISP_MODEL_PATH=... KRISP_VIVA_FILTER_MODEL_PATH=... KRISP_VIVA_TURN_MODEL_PATH=... +# LemonSlice +LEMONSLICE_API_KEY=... +LEMONSLICE_AGENT_ID=... + # LiveKit LIVEKIT_API_KEY=... LIVEKIT_API_SECRET=... diff --git a/examples/foundational/55-lemonslice-transport.py b/examples/foundational/55-lemonslice-transport.py new file mode 100644 index 000000000..0bb4b1d31 --- /dev/null +++ b/examples/foundational/55-lemonslice-transport.py @@ -0,0 +1,117 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +import asyncio +import os +import sys + +import aiohttp +from dotenv import load_dotenv +from loguru import logger + +from pipecat.audio.vad.silero import SileroVADAnalyzer +from pipecat.frames.frames import LLMRunFrame +from pipecat.pipeline.pipeline import Pipeline +from pipecat.pipeline.runner import PipelineRunner +from pipecat.pipeline.task import PipelineParams, PipelineTask +from pipecat.processors.aggregators.llm_context import LLMContext +from pipecat.processors.aggregators.llm_response_universal import ( + LLMContextAggregatorPair, + LLMUserAggregatorParams, +) +from pipecat.services.deepgram.stt import DeepgramSTTService +from pipecat.services.elevenlabs.tts import ElevenLabsTTSService +from pipecat.services.groq.llm import GroqLLMService +from pipecat.transports.lemonslice.transport import LemonSliceParams, LemonSliceTransport + +load_dotenv(override=True) + +logger.remove(0) +logger.add(sys.stderr, level="DEBUG") + + +async def main(): + async with aiohttp.ClientSession() as session: + transport = LemonSliceTransport( + bot_name="Pipecat bot", + api_key=os.getenv("LEMONSLICE_API_KEY"), + agent_id=os.getenv("LEMONSLICE_AGENT_ID"), + session=session, + params=LemonSliceParams( + audio_in_enabled=True, + audio_out_enabled=True, + microphone_out_enabled=False, + ), + ) + + stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) + + llm = GroqLLMService(api_key=os.getenv("GROQ_API_KEY")) + + tts = ElevenLabsTTSService( + api_key=os.getenv("ELEVENLABS_API_KEY", ""), + voice_id="ys3XeJJA4ArWMhRpcX1D", + ) + + messages = [ + { + "role": "system", + "content": "You are a helpful LLM in a WebRTC call. Your goal is to demonstrate your capabilities in a succinct way. Your output will be spoken aloud, so avoid special characters that can't easily be spoken, such as emojis or bullet points. Respond to what the user said in a creative and helpful way.", + }, + ] + + context = LLMContext(messages) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), + ) + + pipeline = Pipeline( + [ + transport.input(), # Transport user input + stt, # STT + user_aggregator, # User responses + llm, # LLM + tts, # TTS + transport.output(), # Transport bot output + assistant_aggregator, # Assistant spoken responses + ] + ) + + task = PipelineTask( + pipeline, + params=PipelineParams( + audio_in_sample_rate=16000, + audio_out_sample_rate=16000, + enable_metrics=True, + enable_usage_metrics=True, + ), + ) + + @transport.event_handler("on_client_connected") + async def on_client_connected(transport, participant): + logger.info(f"Client connected") + # Kick off the conversation. + messages.append( + { + "role": "system", + "content": "Start by greeting the user and ask how you can help.", + } + ) + await task.queue_frames([LLMRunFrame()]) + + @transport.event_handler("on_client_disconnected") + async def on_client_disconnected(transport, participant): + logger.info(f"Client disconnected") + await task.cancel() + + runner = PipelineRunner() + + await runner.run(task) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/foundational/README.md b/examples/foundational/README.md index 9947dd1e6..8fb60a0c2 100644 --- a/examples/foundational/README.md +++ b/examples/foundational/README.md @@ -121,6 +121,7 @@ uv run 07-interruptible.py -t twilio -x NGROK_HOST_NAME - **[19-openai-realtime-beta.py](./19-openai-realtime-beta.py)**: OpenAI Speech-to-Speech (Direct S2S, Function calls) - **[21-tavus-layer-tavus-transport.py](./21-tavus-layer-tavus-transport.py)**: Tavus digital twin (Avatar integration) - **[27-simli-layer.py](./27-simli-layer.py)**: Simli avatar integration (Video synchronization) +- **[55-lemonslice-transport.py](./55-lemonslice-transport.py)**: LemonSlice avatar integration (A/V Synced Avatar integration) ### Performance & Optimization diff --git a/pyproject.toml b/pyproject.toml index db76fa24e..6c6ca66e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,6 +80,7 @@ koala = [ "pvkoala~=2.0.3" ] kokoro = [ "kokoro-onnx>=0.5.0,<1", "requests>=2.32.5,<3" ] krisp = [ "pipecat-ai-krisp~=0.4.0" ] langchain = [ "langchain~=0.3.20", "langchain-community~=0.3.20", "langchain-openai~=0.3.9" ] +lemonslice = [] livekit = [ "livekit~=1.0.13", "livekit-api~=1.0.5", "tenacity>=8.2.3,<10.0.0", "pyjwt>=2.10.1" ] lmnt = [ "pipecat-ai[websockets-base]" ] local = [ "pyaudio~=0.2.14" ] diff --git a/src/pipecat/transports/lemonslice/__init__.py b/src/pipecat/transports/lemonslice/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/pipecat/transports/lemonslice/transport.py b/src/pipecat/transports/lemonslice/transport.py new file mode 100644 index 000000000..b11a92172 --- /dev/null +++ b/src/pipecat/transports/lemonslice/transport.py @@ -0,0 +1,799 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""LemonSlice transport for Pipecat. + +This module adds LemonSlice avatars to Daily rooms, enabling +real-time voice conversations with synchronized avatars. +""" + +from functools import partial +from typing import Any, Awaitable, Callable, Mapping, Optional + +import aiohttp +from daily.daily import AudioData +from loguru import logger +from pydantic import BaseModel + +from pipecat.frames.frames import ( + BotStartedSpeakingFrame, + BotStoppedSpeakingFrame, + CancelFrame, + EndFrame, + Frame, + InputAudioRawFrame, + InterruptionFrame, + OutputAudioRawFrame, + OutputTransportMessageFrame, + OutputTransportMessageUrgentFrame, + StartFrame, +) +from pipecat.processors.frame_processor import FrameDirection, FrameProcessor, FrameProcessorSetup +from pipecat.transports.base_input import BaseInputTransport +from pipecat.transports.base_output import BaseOutputTransport +from pipecat.transports.base_transport import BaseTransport, TransportParams +from pipecat.transports.daily.transport import ( + DailyCallbacks, + DailyParams, + DailyTransportClient, +) +from pipecat.transports.lemonslice.utils import LemonSliceApi + + +class LemonSliceCallbacks(BaseModel): + """Callback handlers for LemonSlice events. + + Parameters: + on_participant_joined: Called when a participant joins the conversation. + on_participant_left: Called when a participant leaves the conversation. + """ + + on_participant_joined: Callable[[Mapping[str, Any]], Awaitable[None]] + on_participant_left: Callable[[Mapping[str, Any], str], Awaitable[None]] + + +class LemonSliceParams(DailyParams): + """Configuration parameters for the LemonSlice transport. + + Parameters: + audio_in_enabled: Whether to enable audio input from participants. + audio_out_enabled: Whether to enable audio output to participants. + microphone_out_enabled: Whether to enable microphone output track. + """ + + audio_in_enabled: bool = True + audio_out_enabled: bool = True + microphone_out_enabled: bool = False + + +class LemonSliceTransportClient: + """Transport client that integrates Pipecat with the LemonSlice platform. + + A transport client that integrates a Pipecat Bot with the LemonSlice platform by managing + conversation sessions using the LemonSlice API. + + This client uses `LemonSliceApi` to interact with the LemonSlice backend. LemonSlice either provides + a room URL where the avatar is already present, or adds the LemonSlice avatar to a Daily room + the user supplies. + """ + + def __init__( + self, + *, + bot_name: str, + params: LemonSliceParams = LemonSliceParams(), + callbacks: LemonSliceCallbacks, + api_key: str, + agent_image_url: Optional[str] = None, + agent_id: Optional[str] = None, + agent_prompt: Optional[str] = None, + idle_timeout: Optional[int] = None, + daily_room_url: Optional[str] = None, + daily_token: Optional[str] = None, + lemonslice_properties: Optional[dict] = None, + session: aiohttp.ClientSession, + ) -> None: + """Initialize the LemonSlice transport client. + + Args: + bot_name: The name of the Pipecat bot instance. + params: Optional parameters for LemonSlice operation. + callbacks: Callback handlers for LemonSlice-related events. + api_key: API key for authenticating with LemonSlice API. + agent_image_url: Optional URL to an agent image. + agent_id: Optional ID of LemonSlice agent. + agent_prompt: Optional system prompt for the avatar. + idle_timeout: Optional idle timeout in seconds. + daily_room_url: Optional Daily room URL to add the LemonSlice avatar to. + daily_token: Optional Daily token for authenticating with the room. + lemonslice_properties: Optional additional properties for the session. + session: The aiohttp session for making async HTTP requests. + """ + self._bot_name = bot_name + self._api = LemonSliceApi(api_key, session) + self._agent_id = agent_id + self._agent_image_url = agent_image_url + self._agent_prompt = agent_prompt + self._idle_timeout = idle_timeout + self._daily_room_url = daily_room_url + self._daily_token = daily_token + self._lemonslice_properties = lemonslice_properties + self._session_id: Optional[str] = None + self._control_url: Optional[str] = None + self._daily_transport_client: Optional[DailyTransportClient] = None + self._callbacks = callbacks + self._params = params + + async def _initialize(self) -> str: + """Initialize the conversation and return the room URL.""" + response = await self._api.create_session( + agent_image_url=self._agent_image_url, + agent_id=self._agent_id, + agent_prompt=self._agent_prompt, + idle_timeout=self._idle_timeout, + daily_room_url=self._daily_room_url, + daily_token=self._daily_token, + properties=self._lemonslice_properties, + ) + self._session_id = response["session_id"] + self._control_url = response["control_url"] + return response["room_url"] + + async def setup(self, setup: FrameProcessorSetup): + """Setup the client and initialize the conversation. + + Args: + setup: The frame processor setup configuration. + """ + if self._session_id is not None: + logger.debug(f"Session ID already defined: {self._session_id}") + return + try: + room_url = await self._initialize() + daily_callbacks = DailyCallbacks( + on_active_speaker_changed=partial( + self._on_handle_callback, "on_active_speaker_changed" + ), + on_joined=self._on_joined, + on_left=self._on_left, + on_before_leave=partial(self._on_handle_callback, "on_before_leave"), + on_error=partial(self._on_handle_callback, "on_error"), + on_app_message=partial(self._on_handle_callback, "on_app_message"), + on_call_state_updated=partial(self._on_handle_callback, "on_call_state_updated"), + on_client_connected=partial(self._on_handle_callback, "on_client_connected"), + on_client_disconnected=partial(self._on_handle_callback, "on_client_disconnected"), + on_dialin_connected=partial(self._on_handle_callback, "on_dialin_connected"), + on_dialin_ready=partial(self._on_handle_callback, "on_dialin_ready"), + on_dialin_stopped=partial(self._on_handle_callback, "on_dialin_stopped"), + on_dialin_error=partial(self._on_handle_callback, "on_dialin_error"), + on_dialin_warning=partial(self._on_handle_callback, "on_dialin_warning"), + on_dialout_answered=partial(self._on_handle_callback, "on_dialout_answered"), + on_dialout_connected=partial(self._on_handle_callback, "on_dialout_connected"), + on_dialout_stopped=partial(self._on_handle_callback, "on_dialout_stopped"), + on_dialout_error=partial(self._on_handle_callback, "on_dialout_error"), + on_dialout_warning=partial(self._on_handle_callback, "on_dialout_warning"), + on_participant_joined=self._callbacks.on_participant_joined, + on_participant_left=self._callbacks.on_participant_left, + on_participant_updated=partial(self._on_handle_callback, "on_participant_updated"), + on_transcription_message=partial( + self._on_handle_callback, "on_transcription_message" + ), + on_recording_started=partial(self._on_handle_callback, "on_recording_started"), + on_recording_stopped=partial(self._on_handle_callback, "on_recording_stopped"), + on_recording_error=partial(self._on_handle_callback, "on_recording_error"), + on_transcription_stopped=partial( + self._on_handle_callback, "on_transcription_stopped" + ), + on_transcription_error=partial(self._on_handle_callback, "on_transcription_error"), + ) + self._daily_transport_client = DailyTransportClient( + room_url, None, self._bot_name, self._params, daily_callbacks, "LemonSlicePipecat" + ) + await self._daily_transport_client.setup(setup) + except Exception as e: + logger.error(f"Failed to setup LemonSliceTransportClient: {e}") + if self._session_id and self._control_url: + await self._api.end_session(self._session_id, self._control_url) + self._session_id = None + self._control_url = None + + async def cleanup(self): + """Cleanup client resources.""" + try: + await self._daily_transport_client.cleanup() + except Exception as e: + logger.error(f"Exception during cleanup: {e}") + + async def _on_joined(self, data): + """Handle joined event.""" + logger.debug("LemonSliceTransportClient joined!") + + async def _on_left(self): + """Handle left event.""" + logger.debug("LemonSliceTransportClient left!") + + async def _on_handle_callback(self, event_name, *args, **kwargs): + """Handle generic callback events.""" + logger.trace(f"[Callback] {event_name} called with args={args}, kwargs={kwargs}") + + async def get_bot_name(self) -> str: + """Get the name of the LemonSlice participant. + + Returns: + The name of the LemonSlice participant. + """ + return "LemonSlice" + + async def start(self, frame: StartFrame): + """Start the client and join the room. + + Args: + frame: The start frame containing initialization parameters. + """ + await self._daily_transport_client.start(frame) + await self._daily_transport_client.join() + + async def stop(self): + """Stop the client and end the conversation.""" + await self._daily_transport_client.leave() + if self._session_id and self._control_url: + await self._api.end_session(self._session_id, self._control_url) + self._session_id = None + self._control_url = None + + async def capture_participant_video( + self, + participant_id: str, + callback: Callable, + framerate: int = 30, + video_source: str = "camera", + color_format: str = "RGB", + ): + """Capture video from a participant. + + Args: + participant_id: ID of the participant to capture video from. + callback: Callback function to handle video frames. + framerate: Desired framerate for video capture. + video_source: Video source to capture from. + color_format: Color format for video frames. + """ + await self._daily_transport_client.capture_participant_video( + participant_id, callback, framerate, video_source, color_format + ) + + async def capture_participant_audio( + self, + participant_id: str, + callback: Callable, + audio_source: str = "microphone", + sample_rate: int = 16000, + callback_interval_ms: int = 20, + ): + """Capture audio from a participant. + + Args: + participant_id: ID of the participant to capture audio from. + callback: Callback function to handle audio data. + audio_source: Audio source to capture from. + sample_rate: Desired sample rate for audio capture. + callback_interval_ms: Interval between audio callbacks in milliseconds. + """ + await self._daily_transport_client.capture_participant_audio( + participant_id, callback, audio_source, sample_rate, callback_interval_ms + ) + + async def send_message( + self, frame: OutputTransportMessageFrame | OutputTransportMessageUrgentFrame + ): + """Send a message to participants. + + Args: + frame: The message frame to send. + """ + await self._daily_transport_client.send_message(frame) + + @property + def out_sample_rate(self) -> int: + """Get the output sample rate. + + Returns: + The output sample rate in Hz. + """ + return self._daily_transport_client.out_sample_rate + + @property + def in_sample_rate(self) -> int: + """Get the input sample rate. + + Returns: + The input sample rate in Hz. + """ + return self._daily_transport_client.in_sample_rate + + async def send_interrupt_message(self) -> None: + """Send an interrupt message to the LemonSlice session.""" + logger.info("Sending interrupt message") + transport_frame = OutputTransportMessageUrgentFrame( + message={ + "event": "interrupt", + "session_id": self._session_id, + } + ) + await self.send_message(transport_frame) + + async def send_response_started_message(self) -> None: + """Send a response_started message to the LemonSlice session.""" + logger.info("Sending response_started message") + transport_frame = OutputTransportMessageUrgentFrame( + message={ + "event": "response_started", + "session_id": self._session_id, + } + ) + await self.send_message(transport_frame) + + async def send_response_finished_message(self) -> None: + """Send a response_finished message to the LemonSlice session.""" + logger.info("Sending response_finished message") + transport_frame = OutputTransportMessageUrgentFrame( + message={ + "event": "response_finished", + "session_id": self._session_id, + } + ) + await self.send_message(transport_frame) + + async def update_subscriptions(self, participant_settings=None, profile_settings=None): + """Update subscription settings for participants. + + Args: + participant_settings: Per-participant subscription settings. + profile_settings: Global subscription profile settings. + """ + if not self._daily_transport_client: + return + + await self._daily_transport_client.update_subscriptions( + participant_settings=participant_settings, profile_settings=profile_settings + ) + + async def write_audio_frame(self, frame: OutputAudioRawFrame) -> bool: + """Write an audio frame to the transport. + + Args: + frame: The audio frame to write. + + Returns: + True if the audio frame was written successfully, False otherwise. + """ + if not self._daily_transport_client: + return False + + return await self._daily_transport_client.write_audio_frame(frame) + + async def register_audio_destination(self, destination: str): + """Register an audio destination for output. + + Args: + destination: The destination identifier to register. + """ + if not self._daily_transport_client: + return + + await self._daily_transport_client.register_audio_destination(destination) + + +class LemonSliceInputTransport(BaseInputTransport): + """Input transport for receiving audio and events from LemonSlice. + + Handles incoming audio streams from participants and manages audio capture + from the Daily room connected to LemonSlice. + """ + + def __init__( + self, + client: LemonSliceTransportClient, + params: TransportParams, + **kwargs, + ): + """Initialize the LemonSlice input transport. + + Args: + client: The LemonSlice transport client instance. + params: Transport configuration parameters. + **kwargs: Additional arguments passed to parent class. + """ + super().__init__(params, **kwargs) + self._client = client + self._params = params + # Whether we have seen a StartFrame already. + self._initialized = False + + async def setup(self, setup: FrameProcessorSetup): + """Setup the input transport. + + Args: + setup: The frame processor setup configuration. + """ + await super().setup(setup) + await self._client.setup(setup) + + async def cleanup(self): + """Cleanup input transport resources.""" + await super().cleanup() + await self._client.cleanup() + + async def start(self, frame: StartFrame): + """Start the input transport. + + Args: + frame: The start frame containing initialization parameters. + """ + await super().start(frame) + + if self._initialized: + return + + self._initialized = True + + await self._client.start(frame) + await self.set_transport_ready(frame) + + async def stop(self, frame: EndFrame): + """Stop the input transport. + + Args: + frame: The end frame signaling transport shutdown. + """ + await super().stop(frame) + await self._client.stop() + + async def cancel(self, frame: CancelFrame): + """Cancel the input transport. + + Args: + frame: The cancel frame signaling immediate cancellation. + """ + await super().cancel(frame) + await self._client.stop() + + async def start_capturing_audio(self, participant): + """Start capturing audio from a participant. + + Args: + participant: The participant to capture audio from. + """ + if self._params.audio_in_enabled: + logger.info( + f"LemonSliceTransportClient start capturing audio for participant {participant['id']}" + ) + await self._client.capture_participant_audio( + participant_id=participant["id"], + callback=self._on_participant_audio_data, + sample_rate=self._client.in_sample_rate, + ) + + async def _on_participant_audio_data( + self, participant_id: str, audio: AudioData, audio_source: str + ): + """Handle received participant audio data. + + Args: + participant_id: ID of the participant who sent the audio. + audio: The audio data from the participant. + audio_source: The source of the audio (e.g., microphone). + """ + frame = InputAudioRawFrame( + audio=audio.audio_frames, + sample_rate=audio.sample_rate, + num_channels=audio.num_channels, + ) + frame.transport_source = audio_source + await self.push_audio_frame(frame) + + +class LemonSliceOutputTransport(BaseOutputTransport): + """Output transport for sending audio and events to LemonSlice. + + Handles outgoing audio streams to participants and manages the custom + audio track expected by the LemonSlice platform. + """ + + def __init__( + self, + client: LemonSliceTransportClient, + params: TransportParams, + **kwargs, + ): + """Initialize the LemonSlice output transport. + + Args: + client: The LemonSlice transport client instance. + params: Transport configuration parameters. + **kwargs: Additional arguments passed to parent class. + """ + super().__init__(params, **kwargs) + self._client = client + self._params = params + + # Whether we have seen a StartFrame already. + self._initialized = False + # This is the custom track destination expected by LemonSlice + self._transport_destination: Optional[str] = "stream" + + async def setup(self, setup: FrameProcessorSetup): + """Setup the output transport. + + Args: + setup: The frame processor setup configuration. + """ + await super().setup(setup) + await self._client.setup(setup) + + async def cleanup(self): + """Cleanup output transport resources.""" + await super().cleanup() + await self._client.cleanup() + + async def start(self, frame: StartFrame): + """Start the output transport. + + Args: + frame: The start frame containing initialization parameters. + """ + await super().start(frame) + + if self._initialized: + return + + self._initialized = True + + await self._client.start(frame) + + if self._transport_destination: + await self._client.register_audio_destination(self._transport_destination) + + await self.set_transport_ready(frame) + + async def stop(self, frame: EndFrame): + """Stop the output transport. + + Args: + frame: The end frame signaling transport shutdown. + """ + await super().stop(frame) + await self._client.stop() + + async def cancel(self, frame: CancelFrame): + """Cancel the output transport. + + Args: + frame: The cancel frame signaling immediate cancellation. + """ + await super().cancel(frame) + await self._client.stop() + + async def send_message( + self, frame: OutputTransportMessageFrame | OutputTransportMessageUrgentFrame + ): + """Send a message to participants. + + Args: + frame: The message frame to send. + """ + logger.info(f"LemonSliceTransport sending message {frame}") + await self._client.send_message(frame) + + async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM): + """Push a frame to the next processor in the pipeline. + + Args: + frame: The frame to push. + direction: The direction to push the frame. + """ + # The BotStartedSpeakingFrame and BotStoppedSpeakingFrame are created inside BaseOutputTransport + # This is a workaround, so we can more reliably be aware when the bot has started or stopped speaking + if direction == FrameDirection.DOWNSTREAM: + if isinstance(frame, BotStartedSpeakingFrame): + await self._handle_response_started() + if isinstance(frame, BotStoppedSpeakingFrame): + await self._handle_response_finished() + await super().push_frame(frame, direction) + + async def process_frame(self, frame: Frame, direction: FrameDirection): + """Process frames and handle interruptions. + + Args: + frame: The frame to process. + direction: The direction of frame flow in the pipeline. + """ + await super().process_frame(frame, direction) + if isinstance(frame, InterruptionFrame): + await self._handle_interruptions() + + async def _handle_interruptions(self): + """Handle interruption events by sending interrupt message.""" + await self._client.send_interrupt_message() + + async def _handle_response_started(self): + """Handle bot started speaking events by sending response_started message.""" + await self._client.send_response_started_message() + + async def _handle_response_finished(self): + """Handle tts response stopped events by sending response_finished message.""" + await self._client.send_response_finished_message() + + async def write_audio_frame(self, frame: OutputAudioRawFrame) -> bool: + """Write an audio frame to the LemonSlice transport. + + Args: + frame: The audio frame to write. + + Returns: + True if the audio frame was written successfully, False otherwise. + """ + # This is the custom track destination expected by LemonSlice + frame.transport_destination = self._transport_destination + return await self._client.write_audio_frame(frame) + + async def register_audio_destination(self, destination: str): + """Register an audio destination. + + Args: + destination: The destination identifier to register. + """ + await self._client.register_audio_destination(destination) + + +class LemonSliceTransport(BaseTransport): + """Transport implementation to add a LemonSlice avatar to Daily calls. + + When used, the Pipecat bot joins the same virtual room as the LemonSlice Avatar and the user. + This is achieved by using `LemonSliceTransportClient`, which initiates the conversation via + `LemonSliceApi` and obtains a room URL that all participants connect to. + + Event handlers available: + + - on_client_connected(transport, participant): Participant connected to the session + - on_client_disconnected(transport, participant): Participant disconnected from the session + + Example:: + + @transport.event_handler("on_client_connected") + async def on_client_connected(transport, participant): + ... + """ + + def __init__( + self, + bot_name: str, + session: aiohttp.ClientSession, + api_key: str, + agent_image_url: Optional[str] = None, + agent_id: Optional[str] = None, + agent_prompt: Optional[str] = None, + idle_timeout: Optional[int] = None, + params: LemonSliceParams = LemonSliceParams(), + input_name: Optional[str] = None, + output_name: Optional[str] = None, + daily_room_url: Optional[str] = None, + daily_token: Optional[str] = None, + lemonslice_properties: dict = {}, + ): + """Initialize the LemonSlice transport. + + Args: + bot_name: The name of the Pipecat bot. + session: aiohttp session used for async HTTP requests. + api_key: LemonSlice API key for authentication. + agent_image_url: Optional URL to an agent image. + agent_id: Optional ID of the LemonSlice agent. + agent_prompt: Optional system prompt for the avatar. + idle_timeout: Optional idle timeout in seconds. + params: Optional LemonSlice-specific configuration parameters. + input_name: Optional name for the input transport. + output_name: Optional name for the output transport. + daily_room_url: Optional Daily room URL to add the LemonSlice avatar to. + daily_token: Optional Daily token for authenticating with the room. + lemonslice_properties: Optional additional properties for the session. + """ + super().__init__(input_name=input_name, output_name=output_name) + self._params = params + + callbacks = LemonSliceCallbacks( + on_participant_joined=self._on_participant_joined, + on_participant_left=self._on_participant_left, + ) + self._client = LemonSliceTransportClient( + bot_name="Pipecat", + callbacks=callbacks, + api_key=api_key, + agent_image_url=agent_image_url, + agent_id=agent_id, + agent_prompt=agent_prompt, + idle_timeout=idle_timeout, + daily_room_url=daily_room_url, + daily_token=daily_token, + lemonslice_properties=lemonslice_properties, + session=session, + params=params, + ) + self._input: Optional[LemonSliceInputTransport] = None + self._output: Optional[LemonSliceOutputTransport] = None + self._lemonslice_participant_id = None + + # Register supported handlers. The user will only be able to register + # these handlers. + self._register_event_handler("on_client_connected") + self._register_event_handler("on_client_disconnected") + + async def _on_participant_left(self, participant, reason): + """Handle participant left events.""" + ls_bot_name = await self._client.get_bot_name() + if participant.get("info", {}).get("userName", "") != ls_bot_name: + await self._on_client_disconnected(participant) + + async def _on_participant_joined(self, participant): + """Handle participant joined events.""" + ls_bot_name = await self._client.get_bot_name() + + # Ignore the LemonSlice bot's microphone + if participant.get("info", {}).get("userName", "") == ls_bot_name: + self._lemonslice_participant_id = participant["id"] + else: + await self._on_client_connected(participant) + if self._lemonslice_participant_id: + logger.debug(f"Ignoring {self._lemonslice_participant_id}'s microphone") + await self.update_subscriptions( + participant_settings={ + self._lemonslice_participant_id: { + "media": {"microphone": "unsubscribed"}, + } + } + ) + if self._input: + await self._input.start_capturing_audio(participant) + + async def update_subscriptions(self, participant_settings=None, profile_settings=None): + """Update subscription settings for participants. + + Args: + participant_settings: Per-participant subscription settings. + profile_settings: Global subscription profile settings. + """ + await self._client.update_subscriptions( + participant_settings=participant_settings, + profile_settings=profile_settings, + ) + + def input(self) -> FrameProcessor: + """Get the input transport for receiving media and events. + + Returns: + The LemonSlice input transport instance. + """ + if not self._input: + self._input = LemonSliceInputTransport(client=self._client, params=self._params) + return self._input + + def output(self) -> FrameProcessor: + """Get the output transport for sending media and events. + + Returns: + The LemonSlice output transport instance. + """ + if not self._output: + self._output = LemonSliceOutputTransport(client=self._client, params=self._params) + return self._output + + async def _on_client_connected(self, participant: Any): + """Handle client connected events.""" + await self._call_event_handler("on_client_connected", participant) + + async def _on_client_disconnected(self, participant: Any): + """Handle client disconnected events.""" + await self._call_event_handler("on_client_disconnected", participant) diff --git a/src/pipecat/transports/lemonslice/utils.py b/src/pipecat/transports/lemonslice/utils.py new file mode 100644 index 000000000..98aac3ccb --- /dev/null +++ b/src/pipecat/transports/lemonslice/utils.py @@ -0,0 +1,108 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""LemonSlice API utilities for session management. + +This module provides helper classes for interacting with the LemonSlice API, +including session creation and termination. +""" + +from typing import Any, Optional + +import aiohttp +from loguru import logger + + +class LemonSliceApi: + """Helper class for interacting with the LemonSlice API. + + Provides methods for creating and managing sessions with LemonSlice avatars. + """ + + LEMONSLICE_URL = "https://lemonslice.com/api/liveai/sessions" + + def __init__(self, api_key: str, session: aiohttp.ClientSession): + """Initialize the LemonSliceApi client. + + Args: + api_key: LemonSlice API key for authentication. + session: An aiohttp session for making HTTP requests. + """ + self._api_key = api_key + self._session = session + self._headers = {"Content-Type": "application/json", "x-api-key": self._api_key} + + async def create_session( + self, + *, + agent_image_url: Optional[str] = None, + agent_id: Optional[str] = None, + agent_prompt: Optional[str] = None, + idle_timeout: Optional[int] = None, + daily_room_url: Optional[str] = None, + daily_token: Optional[str] = None, + properties: Optional[dict[str, Any]] = None, + ) -> dict: + """Create a new session with the specified agent_id or agent_image_url. + + Args: + agent_image_url: The URL to an agent image. Provide either agent_id or agent_image_url. + agent_id: ID of a LemonSlice agent. Provide either agent_id or agent_image_url. + agent_prompt: A high-level system prompt that subtly influences the avatar’s movements, expressions, and emotional demeanor. + idle_timeout: Idle timeout in seconds. + daily_room_url: Daily room URL to use for the session. + daily_token: Daily token for authenticating with the room. + properties: Additional properties to pass to the session. + + Returns: + Dictionary containing session_id, room_url, and control_url. + + Raises: + ValueError: If neither agent_id nor agent_image_url is provided. + """ + if not agent_id and not agent_image_url: + raise ValueError("Provide either agent_id or agent_image_url") + if agent_id and agent_image_url: + raise ValueError("Provide exactly one of agent_id or agent_image_url, not both") + + logger.debug( + f"Creating LemonSlice session: agent_id={agent_id}, agent_image_url={agent_image_url}" + ) + payload: dict[str, object] = {"transport_type": "daily"} + if agent_id is not None: + payload["agent_id"] = agent_id + if agent_image_url is not None: + payload["agent_image_url"] = agent_image_url + if agent_prompt is not None: + payload["agent_prompt"] = agent_prompt + if idle_timeout is not None: + payload["idle_timeout"] = idle_timeout + properties_dict: dict[str, Any] = dict(properties) if properties else {} + if daily_room_url is not None: + properties_dict["daily_url"] = daily_room_url + if daily_token is not None: + properties_dict["daily_token"] = daily_token + if properties_dict: + payload["properties"] = properties_dict + async with self._session.post( + self.LEMONSLICE_URL, headers=self._headers, json=payload + ) as r: + r.raise_for_status() + response = await r.json() + logger.debug(f"Created LemonSlice session: {response}") + return response + + async def end_session(self, session_id: str, control_url: str): + """End an existing session. + + Args: + session_id: ID of the session to end. + control_url: The control URL from the create_session response. + """ + payload = {"event": "terminate"} + async with self._session.post(control_url, headers=self._headers, json=payload) as r: + r.raise_for_status() + logger.debug(f"Ended LemonSlice session {session_id}") diff --git a/uv.lock b/uv.lock index 06563ab45..527ffb217 100644 --- a/uv.lock +++ b/uv.lock @@ -4762,7 +4762,7 @@ requires-dist = [ { name = "wait-for2", marker = "python_full_version < '3.12'", specifier = ">=0.4.1" }, { name = "websockets", marker = "extra == 'websockets-base'", specifier = ">=13.1,<16.0" }, ] -provides-extras = ["aic", "anthropic", "assemblyai", "asyncai", "aws", "aws-nova-sonic", "azure", "cartesia", "camb", "cerebras", "daily", "deepgram", "deepseek", "elevenlabs", "fal", "fireworks", "fish", "gladia", "google", "gradium", "grok", "groq", "gstreamer", "heygen", "hume", "inworld", "koala", "kokoro", "krisp", "langchain", "livekit", "lmnt", "local", "local-smart-turn", "local-smart-turn-v3", "mcp", "mem0", "mistral", "mlx-whisper", "moondream", "neuphonic", "noisereduce", "nvidia", "openai", "rnnoise", "openpipe", "openrouter", "perplexity", "piper", "playht", "qwen", "remote-smart-turn", "resembleai", "rime", "riva", "runner", "sagemaker", "sambanova", "sarvam", "sentry", "silero", "simli", "soniox", "soundfile", "speechmatics", "strands", "tavus", "together", "tracing", "ultravox", "webrtc", "websocket", "websockets-base", "whisper"] +provides-extras = ["aic", "anthropic", "assemblyai", "asyncai", "aws", "aws-nova-sonic", "azure", "cartesia", "camb", "cerebras", "daily", "deepgram", "deepseek", "elevenlabs", "fal", "fireworks", "fish", "gladia", "google", "gradium", "grok", "groq", "gstreamer", "heygen", "hume", "inworld", "koala", "kokoro", "krisp", "langchain", "lemonslice", "livekit", "lmnt", "local", "local-smart-turn", "local-smart-turn-v3", "mcp", "mem0", "mistral", "mlx-whisper", "moondream", "neuphonic", "noisereduce", "nvidia", "openai", "rnnoise", "openpipe", "openrouter", "perplexity", "piper", "playht", "qwen", "remote-smart-turn", "resembleai", "rime", "riva", "runner", "sagemaker", "sambanova", "sarvam", "sentry", "silero", "simli", "soniox", "soundfile", "speechmatics", "strands", "tavus", "together", "tracing", "ultravox", "webrtc", "websocket", "websockets-base", "whisper"] [package.metadata.requires-dev] dev = [ @@ -7586,31 +7586,31 @@ wheels = [ [[package]] name = "uuid-utils" -version = "0.14.0" +version = "0.14.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/7c/3a926e847516e67bc6838634f2e54e24381105b4e80f9338dc35cca0086b/uuid_utils-0.14.0.tar.gz", hash = "sha256:fc5bac21e9933ea6c590433c11aa54aaca599f690c08069e364eb13a12f670b4", size = 22072, upload-time = "2026-01-20T20:37:15.729Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/d1/38a573f0c631c062cf42fa1f5d021d4dd3c31fb23e4376e4b56b0c9fbbed/uuid_utils-0.14.1.tar.gz", hash = "sha256:9bfc95f64af80ccf129c604fb6b8ca66c6f256451e32bc4570f760e4309c9b69", size = 22195, upload-time = "2026-02-20T22:50:38.833Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/42/42d003f4a99ddc901eef2fd41acb3694163835e037fb6dde79ad68a72342/uuid_utils-0.14.0-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:f6695c0bed8b18a904321e115afe73b34444bc8451d0ce3244a1ec3b84deb0e5", size = 601786, upload-time = "2026-01-20T20:37:09.843Z" }, - { url = "https://files.pythonhosted.org/packages/96/e6/775dfb91f74b18f7207e3201eb31ee666d286579990dc69dd50db2d92813/uuid_utils-0.14.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:4f0a730bbf2d8bb2c11b93e1005e91769f2f533fa1125ed1f00fd15b6fcc732b", size = 303943, upload-time = "2026-01-20T20:37:18.767Z" }, - { url = "https://files.pythonhosted.org/packages/17/82/ea5f5e85560b08a1f30cdc65f75e76494dc7aba9773f679e7eaa27370229/uuid_utils-0.14.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40ce3fd1a4fdedae618fc3edc8faf91897012469169d600133470f49fd699ed3", size = 340467, upload-time = "2026-01-20T20:37:11.794Z" }, - { url = "https://files.pythonhosted.org/packages/ca/33/54b06415767f4569882e99b6470c6c8eeb97422686a6d432464f9967fd91/uuid_utils-0.14.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:09ae4a98416a440e78f7d9543d11b11cae4bab538b7ed94ec5da5221481748f2", size = 346333, upload-time = "2026-01-20T20:37:12.818Z" }, - { url = "https://files.pythonhosted.org/packages/cb/10/a6bce636b8f95e65dc84bf4a58ce8205b8e0a2a300a38cdbc83a3f763d27/uuid_utils-0.14.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:971e8c26b90d8ae727e7f2ac3ee23e265971d448b3672882f2eb44828b2b8c3e", size = 470859, upload-time = "2026-01-20T20:37:01.512Z" }, - { url = "https://files.pythonhosted.org/packages/8a/27/84121c51ea72f013f0e03d0886bcdfa96b31c9b83c98300a7bd5cc4fa191/uuid_utils-0.14.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5cde1fa82804a8f9d2907b7aec2009d440062c63f04abbdb825fce717a5e860", size = 341988, upload-time = "2026-01-20T20:37:22.881Z" }, - { url = "https://files.pythonhosted.org/packages/90/a4/01c1c7af5e6a44f20b40183e8dac37d6ed83e7dc9e8df85370a15959b804/uuid_utils-0.14.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7343862a2359e0bd48a7f3dfb5105877a1728677818bb694d9f40703264a2db", size = 365784, upload-time = "2026-01-20T20:37:10.808Z" }, - { url = "https://files.pythonhosted.org/packages/04/f0/65ee43ec617b8b6b1bf2a5aecd56a069a08cca3d9340c1de86024331bde3/uuid_utils-0.14.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c51e4818fdb08ccec12dc7083a01f49507b4608770a0ab22368001685d59381b", size = 523750, upload-time = "2026-01-20T20:37:06.152Z" }, - { url = "https://files.pythonhosted.org/packages/95/d3/6bf503e3f135a5dfe705a65e6f89f19bccd55ac3fb16cb5d3ec5ba5388b8/uuid_utils-0.14.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:181bbcccb6f93d80a8504b5bd47b311a1c31395139596edbc47b154b0685b533", size = 615818, upload-time = "2026-01-20T20:37:21.816Z" }, - { url = "https://files.pythonhosted.org/packages/df/6c/99937dd78d07f73bba831c8dc9469dfe4696539eba2fc269ae1b92752f9e/uuid_utils-0.14.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:5c8ae96101c3524ba8dbf762b6f05e9e9d896544786c503a727c5bf5cb9af1a7", size = 580831, upload-time = "2026-01-20T20:37:19.691Z" }, - { url = "https://files.pythonhosted.org/packages/44/fa/bbc9e2c25abd09a293b9b097a0d8fc16acd6a92854f0ec080f1ea7ad8bb3/uuid_utils-0.14.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:00ac3c6edfdaff7e1eed041f4800ae09a3361287be780d7610a90fdcde9befdc", size = 546333, upload-time = "2026-01-20T20:37:03.117Z" }, - { url = "https://files.pythonhosted.org/packages/e7/9b/e5e99b324b1b5f0c62882230455786df0bc66f67eff3b452447e703f45d2/uuid_utils-0.14.0-cp39-abi3-win32.whl", hash = "sha256:ec2fd80adf8e0e6589d40699e6f6df94c93edcc16dd999be0438dd007c77b151", size = 177319, upload-time = "2026-01-20T20:37:04.208Z" }, - { url = "https://files.pythonhosted.org/packages/d3/28/2c7d417ea483b6ff7820c948678fdf2ac98899dc7e43bb15852faa95acaf/uuid_utils-0.14.0-cp39-abi3-win_amd64.whl", hash = "sha256:efe881eb43a5504fad922644cb93d725fd8a6a6d949bd5a4b4b7d1a1587c7fd1", size = 182566, upload-time = "2026-01-20T20:37:16.868Z" }, - { url = "https://files.pythonhosted.org/packages/b8/86/49e4bdda28e962fbd7266684171ee29b3d92019116971d58783e51770745/uuid_utils-0.14.0-cp39-abi3-win_arm64.whl", hash = "sha256:32b372b8fd4ebd44d3a219e093fe981af4afdeda2994ee7db208ab065cfcd080", size = 182809, upload-time = "2026-01-20T20:37:05.139Z" }, - { url = "https://files.pythonhosted.org/packages/f1/03/1f1146e32e94d1f260dfabc81e1649102083303fb4ad549775c943425d9a/uuid_utils-0.14.0-pp311-pypy311_pp73-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:762e8d67992ac4d2454e24a141a1c82142b5bde10409818c62adbe9924ebc86d", size = 587430, upload-time = "2026-01-20T20:37:24.998Z" }, - { url = "https://files.pythonhosted.org/packages/87/ba/d5a7469362594d885fd9219fe9e851efbe65101d3ef1ef25ea321d7ce841/uuid_utils-0.14.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:40be5bf0b13aa849d9062abc86c198be6a25ff35316ce0b89fc25f3bac6d525e", size = 298106, upload-time = "2026-01-20T20:37:23.896Z" }, - { url = "https://files.pythonhosted.org/packages/8a/11/3dafb2a5502586f59fd49e93f5802cd5face82921b3a0f3abb5f357cb879/uuid_utils-0.14.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:191a90a6f3940d1b7322b6e6cceff4dd533c943659e0a15f788674407856a515", size = 333423, upload-time = "2026-01-20T20:37:17.828Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f2/c8987663f0cdcf4d717a36d85b5db2a5589df0a4e129aa10f16f4380ef48/uuid_utils-0.14.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4aa4525f4ad82f9d9c842f9a3703f1539c1808affbaec07bb1b842f6b8b96aa5", size = 338659, upload-time = "2026-01-20T20:37:14.286Z" }, - { url = "https://files.pythonhosted.org/packages/d1/c8/929d81665d83f0b2ffaecb8e66c3091a50f62c7cb5b65e678bd75a96684e/uuid_utils-0.14.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cdbd82ff20147461caefc375551595ecf77ebb384e46267f128aca45a0f2cdfc", size = 467029, upload-time = "2026-01-20T20:37:08.277Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a0/27d7daa1bfed7163f4ccaf52d7d2f4ad7bb1002a85b45077938b91ee584f/uuid_utils-0.14.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eff57e8a5d540006ce73cf0841a643d445afe78ba12e75ac53a95ca2924a56be", size = 333298, upload-time = "2026-01-20T20:37:07.271Z" }, - { url = "https://files.pythonhosted.org/packages/63/d4/acad86ce012b42ce18a12f31ee2aa3cbeeb98664f865f05f68c882945913/uuid_utils-0.14.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3fd9112ca96978361201e669729784f26c71fecc9c13a7f8a07162c31bd4d1e2", size = 359217, upload-time = "2026-01-20T20:36:59.687Z" }, + { url = "https://files.pythonhosted.org/packages/43/b7/add4363039a34506a58457d96d4aa2126061df3a143eb4d042aedd6a2e76/uuid_utils-0.14.1-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:93a3b5dc798a54a1feb693f2d1cb4cf08258c32ff05ae4929b5f0a2ca624a4f0", size = 604679, upload-time = "2026-02-20T22:50:27.469Z" }, + { url = "https://files.pythonhosted.org/packages/dd/84/d1d0bef50d9e66d31b2019997c741b42274d53dde2e001b7a83e9511c339/uuid_utils-0.14.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:ccd65a4b8e83af23eae5e56d88034b2fe7264f465d3e830845f10d1591b81741", size = 309346, upload-time = "2026-02-20T22:50:31.857Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ed/b6d6fd52a6636d7c3eddf97d68da50910bf17cd5ac221992506fb56cf12e/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b56b0cacd81583834820588378e432b0696186683b813058b707aedc1e16c4b1", size = 344714, upload-time = "2026-02-20T22:50:42.642Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a7/a19a1719fb626fe0b31882db36056d44fe904dc0cf15b06fdf56b2679cf7/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb3cf14de789097320a3c56bfdfdd51b1225d11d67298afbedee7e84e3837c96", size = 350914, upload-time = "2026-02-20T22:50:36.487Z" }, + { url = "https://files.pythonhosted.org/packages/1d/fc/f6690e667fdc3bb1a73f57951f97497771c56fe23e3d302d7404be394d4f/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e0854a90d67f4b0cc6e54773deb8be618f4c9bad98d3326f081423b5d14fae", size = 482609, upload-time = "2026-02-20T22:50:37.511Z" }, + { url = "https://files.pythonhosted.org/packages/54/6e/dcd3fa031320921a12ec7b4672dea3bd1dd90ddffa363a91831ba834d559/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce6743ba194de3910b5feb1a62590cd2587e33a73ab6af8a01b642ceb5055862", size = 345699, upload-time = "2026-02-20T22:50:46.87Z" }, + { url = "https://files.pythonhosted.org/packages/04/28/e5220204b58b44ac0047226a9d016a113fde039280cc8732d9e6da43b39f/uuid_utils-0.14.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:043fb58fde6cf1620a6c066382f04f87a8e74feb0f95a585e4ed46f5d44af57b", size = 372205, upload-time = "2026-02-20T22:50:28.438Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d9/3d2eb98af94b8dfffc82b6a33b4dfc87b0a5de2c68a28f6dde0db1f8681b/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c915d53f22945e55fe0d3d3b0b87fd965a57f5fd15666fd92d6593a73b1dd297", size = 521836, upload-time = "2026-02-20T22:50:23.057Z" }, + { url = "https://files.pythonhosted.org/packages/a8/15/0eb106cc6fe182f7577bc0ab6e2f0a40be247f35c5e297dbf7bbc460bd02/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:0972488e3f9b449e83f006ead5a0e0a33ad4a13e4462e865b7c286ab7d7566a3", size = 625260, upload-time = "2026-02-20T22:50:25.949Z" }, + { url = "https://files.pythonhosted.org/packages/3c/17/f539507091334b109e7496830af2f093d9fc8082411eafd3ece58af1f8ba/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:1c238812ae0c8ffe77d8d447a32c6dfd058ea4631246b08b5a71df586ff08531", size = 587824, upload-time = "2026-02-20T22:50:35.225Z" }, + { url = "https://files.pythonhosted.org/packages/2e/c2/d37a7b2e41f153519367d4db01f0526e0d4b06f1a4a87f1c5dfca5d70a8b/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:bec8f8ef627af86abf8298e7ec50926627e29b34fa907fcfbedb45aaa72bca43", size = 551407, upload-time = "2026-02-20T22:50:44.915Z" }, + { url = "https://files.pythonhosted.org/packages/65/36/2d24b2cbe78547c6532da33fb8613debd3126eccc33a6374ab788f5e46e9/uuid_utils-0.14.1-cp39-abi3-win32.whl", hash = "sha256:b54d6aa6252d96bac1fdbc80d26ba71bad9f220b2724d692ad2f2310c22ef523", size = 183476, upload-time = "2026-02-20T22:50:32.745Z" }, + { url = "https://files.pythonhosted.org/packages/83/92/2d7e90df8b1a69ec4cff33243ce02b7a62f926ef9e2f0eca5a026889cd73/uuid_utils-0.14.1-cp39-abi3-win_amd64.whl", hash = "sha256:fc27638c2ce267a0ce3e06828aff786f91367f093c80625ee21dad0208e0f5ba", size = 187147, upload-time = "2026-02-20T22:50:45.807Z" }, + { url = "https://files.pythonhosted.org/packages/d9/26/529f4beee17e5248e37e0bc17a2761d34c0fa3b1e5729c88adb2065bae6e/uuid_utils-0.14.1-cp39-abi3-win_arm64.whl", hash = "sha256:b04cb49b42afbc4ff8dbc60cf054930afc479d6f4dd7f1ec3bbe5dbfdde06b7a", size = 188132, upload-time = "2026-02-20T22:50:41.718Z" }, + { url = "https://files.pythonhosted.org/packages/91/f9/6c64bdbf71f58ccde7919e00491812556f446a5291573af92c49a5e9aaef/uuid_utils-0.14.1-pp311-pypy311_pp73-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:b197cd5424cf89fb019ca7f53641d05bfe34b1879614bed111c9c313b5574cd8", size = 591617, upload-time = "2026-02-20T22:50:24.532Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f0/758c3b0fb0c4871c7704fef26a5bc861de4f8a68e4831669883bebe07b0f/uuid_utils-0.14.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:12c65020ba6cb6abe1d57fcbfc2d0ea0506c67049ee031714057f5caf0f9bc9c", size = 303702, upload-time = "2026-02-20T22:50:40.687Z" }, + { url = "https://files.pythonhosted.org/packages/85/89/d91862b544c695cd58855efe3201f83894ed82fffe34500774238ab8eba7/uuid_utils-0.14.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b5d2ad28063d422ccc2c28d46471d47b61a58de885d35113a8f18cb547e25bf", size = 337678, upload-time = "2026-02-20T22:50:39.768Z" }, + { url = "https://files.pythonhosted.org/packages/ee/6b/cf342ba8a898f1de024be0243fac67c025cad530c79ea7f89c4ce718891a/uuid_utils-0.14.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:da2234387b45fde40b0fedfee64a0ba591caeea9c48c7698ab6e2d85c7991533", size = 343711, upload-time = "2026-02-20T22:50:43.965Z" }, + { url = "https://files.pythonhosted.org/packages/b3/20/049418d094d396dfa6606b30af925cc68a6670c3b9103b23e6990f84b589/uuid_utils-0.14.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50fffc2827348c1e48972eed3d1c698959e63f9d030aa5dd82ba451113158a62", size = 476731, upload-time = "2026-02-20T22:50:30.589Z" }, + { url = "https://files.pythonhosted.org/packages/77/a1/0857f64d53a90321e6a46a3d4cc394f50e1366132dcd2ae147f9326ca98b/uuid_utils-0.14.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1dbe718765f70f5b7f9b7f66b6a937802941b1cc56bcf642ce0274169741e01", size = 338902, upload-time = "2026-02-20T22:50:33.927Z" }, + { url = "https://files.pythonhosted.org/packages/ed/d0/5bf7cbf1ac138c92b9ac21066d18faf4d7e7f651047b700eb192ca4b9fdb/uuid_utils-0.14.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:258186964039a8e36db10810c1ece879d229b01331e09e9030bc5dcabe231bd2", size = 364700, upload-time = "2026-02-20T22:50:21.732Z" }, ] [[package]] From 0b4568843b732d3be4ba20d0060fd8b6c85c9101 Mon Sep 17 00:00:00 2001 From: Joshua Primas Date: Fri, 20 Feb 2026 15:59:52 -0800 Subject: [PATCH 02/68] Improved logging + error handling + pipecat bot name usage --- examples/foundational/55-lemonslice-transport.py | 6 +++--- pyproject.toml | 2 +- src/pipecat/transports/lemonslice/transport.py | 12 +++++++----- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/examples/foundational/55-lemonslice-transport.py b/examples/foundational/55-lemonslice-transport.py index 0bb4b1d31..c9f080567 100644 --- a/examples/foundational/55-lemonslice-transport.py +++ b/examples/foundational/55-lemonslice-transport.py @@ -36,7 +36,7 @@ logger.add(sys.stderr, level="DEBUG") async def main(): async with aiohttp.ClientSession() as session: transport = LemonSliceTransport( - bot_name="Pipecat bot", + bot_name="Pipecat", api_key=os.getenv("LEMONSLICE_API_KEY"), agent_id=os.getenv("LEMONSLICE_AGENT_ID"), session=session, @@ -93,7 +93,7 @@ async def main(): @transport.event_handler("on_client_connected") async def on_client_connected(transport, participant): - logger.info(f"Client connected") + logger.info("Client connected") # Kick off the conversation. messages.append( { @@ -105,7 +105,7 @@ async def main(): @transport.event_handler("on_client_disconnected") async def on_client_disconnected(transport, participant): - logger.info(f"Client disconnected") + logger.info("Client disconnected") await task.cancel() runner = PipelineRunner() diff --git a/pyproject.toml b/pyproject.toml index 6c6ca66e3..b312d553e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,7 +80,7 @@ koala = [ "pvkoala~=2.0.3" ] kokoro = [ "kokoro-onnx>=0.5.0,<1", "requests>=2.32.5,<3" ] krisp = [ "pipecat-ai-krisp~=0.4.0" ] langchain = [ "langchain~=0.3.20", "langchain-community~=0.3.20", "langchain-openai~=0.3.9" ] -lemonslice = [] +lemonslice = [ "pipecat-ai[daily]" ] livekit = [ "livekit~=1.0.13", "livekit-api~=1.0.5", "tenacity>=8.2.3,<10.0.0", "pyjwt>=2.10.1" ] lmnt = [ "pipecat-ai[websockets-base]" ] local = [ "pyaudio~=0.2.14" ] diff --git a/src/pipecat/transports/lemonslice/transport.py b/src/pipecat/transports/lemonslice/transport.py index b11a92172..9ef014f79 100644 --- a/src/pipecat/transports/lemonslice/transport.py +++ b/src/pipecat/transports/lemonslice/transport.py @@ -199,11 +199,13 @@ class LemonSliceTransportClient: await self._api.end_session(self._session_id, self._control_url) self._session_id = None self._control_url = None + raise async def cleanup(self): """Cleanup client resources.""" try: - await self._daily_transport_client.cleanup() + if self._daily_transport_client: + await self._daily_transport_client.cleanup() except Exception as e: logger.error(f"Exception during cleanup: {e}") @@ -316,7 +318,7 @@ class LemonSliceTransportClient: async def send_interrupt_message(self) -> None: """Send an interrupt message to the LemonSlice session.""" - logger.info("Sending interrupt message") + logger.debug("Sending interrupt message") transport_frame = OutputTransportMessageUrgentFrame( message={ "event": "interrupt", @@ -338,7 +340,7 @@ class LemonSliceTransportClient: async def send_response_finished_message(self) -> None: """Send a response_finished message to the LemonSlice session.""" - logger.info("Sending response_finished message") + logger.debug("Sending response_finished message") transport_frame = OutputTransportMessageUrgentFrame( message={ "event": "response_finished", @@ -682,7 +684,7 @@ class LemonSliceTransport(BaseTransport): output_name: Optional[str] = None, daily_room_url: Optional[str] = None, daily_token: Optional[str] = None, - lemonslice_properties: dict = {}, + lemonslice_properties: Optional[dict] = None, ): """Initialize the LemonSlice transport. @@ -709,7 +711,7 @@ class LemonSliceTransport(BaseTransport): on_participant_left=self._on_participant_left, ) self._client = LemonSliceTransportClient( - bot_name="Pipecat", + bot_name=bot_name, callbacks=callbacks, api_key=api_key, agent_image_url=agent_image_url, From d38b1d97d4c65a0b0acc6e84047679788d5ab523 Mon Sep 17 00:00:00 2001 From: Joshua Primas Date: Fri, 20 Feb 2026 16:13:44 -0800 Subject: [PATCH 03/68] Added changelog --- changelog/3791.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/3791.added.md diff --git a/changelog/3791.added.md b/changelog/3791.added.md new file mode 100644 index 000000000..89767de5e --- /dev/null +++ b/changelog/3791.added.md @@ -0,0 +1 @@ +- Added `LemonSliceTransport` and `LemonSliceApi` to support adding real-time LemonSlice Avatars to any Daily room. \ No newline at end of file From 72934bd8ae23de1d4c34e75350e168faa8fe54a6 Mon Sep 17 00:00:00 2001 From: zack Date: Thu, 26 Feb 2026 21:35:47 -0500 Subject: [PATCH 04/68] Add u3-rt-pro support and improvements to AssemblyAI STT service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix speaker diarization: Add field alias for speaker_label → speaker mapping in TurnMessage model - Add warning for non-optimal min_end_of_turn_silence_when_confident values (recommends 100ms for best latency) - Improve max_turn_silence override warning message clarity - Update custom prompt warning (remove 88% accuracy claim) - Add comprehensive logging for debugging: - Log final connection params after modifications - Log WebSocket URL and parsed parameters - Log speaker field in transcripts - Log text sent to LLM with speaker formatting - Support dynamic configuration updates via STTUpdateSettingsFrame: - keyterms_prompt (when AssemblyAI API supports it) - prompt - max_turn_silence - min_end_of_turn_silence_when_confident --- src/pipecat/services/assemblyai/models.py | 45 ++- src/pipecat/services/assemblyai/stt.py | 441 ++++++++++++++++++---- 2 files changed, 408 insertions(+), 78 deletions(-) diff --git a/src/pipecat/services/assemblyai/models.py b/src/pipecat/services/assemblyai/models.py index ca58cb848..fb883ac99 100644 --- a/src/pipecat/services/assemblyai/models.py +++ b/src/pipecat/services/assemblyai/models.py @@ -12,7 +12,7 @@ transcription WebSocket messages and connection configuration. from typing import List, Literal, Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class Word(BaseModel): @@ -68,8 +68,16 @@ class TurnMessage(BaseMessage): transcript: The transcribed text for this turn. end_of_turn_confidence: Confidence score for end-of-turn detection. words: List of individual words with timing and confidence data. + language_code: Detected language code (e.g., "es", "fr"). Only present with + complete utterances or when end_of_turn is True. + language_confidence: Confidence score (0-1) for language detection. Only present + with complete utterances or when end_of_turn is True. + speaker: Speaker label (e.g., "A", "B"). Only present when speaker_labels is + enabled and end_of_turn is True. Maps to 'speaker_label' in JSON response. """ + model_config = ConfigDict(populate_by_name=True) + type: Literal["Turn"] = "Turn" turn_order: int turn_is_formatted: bool @@ -77,6 +85,21 @@ class TurnMessage(BaseMessage): transcript: str end_of_turn_confidence: float words: List[Word] + language_code: Optional[str] = None + language_confidence: Optional[float] = None + speaker: Optional[str] = Field(default=None, alias="speaker_label") + + +class SpeechStartedMessage(BaseMessage): + """Message sent when speech is first detected in the audio stream. + + Parameters: + type: Always "SpeechStarted" for this message type. + timestamp: Audio timestamp in milliseconds when speech was detected. + """ + + type: Literal["SpeechStarted"] = "SpeechStarted" + timestamp: int class TerminationMessage(BaseMessage): @@ -94,7 +117,7 @@ class TerminationMessage(BaseMessage): # Union type for all possible message types -AnyMessage = BeginMessage | TurnMessage | TerminationMessage +AnyMessage = BeginMessage | TurnMessage | SpeechStartedMessage | TerminationMessage class AssemblyAIConnectionParams(BaseModel): @@ -109,7 +132,15 @@ class AssemblyAIConnectionParams(BaseModel): min_end_of_turn_silence_when_confident: Minimum silence duration when confident about end-of-turn. max_turn_silence: Maximum silence duration before forcing end-of-turn. keyterms_prompt: List of key terms to guide transcription. Will be JSON serialized before sending. - speech_model: Select between English and multilingual models. Defaults to "universal-streaming-english". + prompt: Optional text prompt to guide the transcription. Only used when speech_model is "u3-rt-pro". + speech_model: Select between English, multilingual, and u3-rt-pro models. Defaults to "u3-rt-pro". + language_detection: Enable automatic language detection. Only applicable to + universal-streaming-multilingual. When enabled, Turn messages include + language_code and language_confidence fields. Defaults to None (not sent). + format_turns: Whether to format transcript turns. Defaults to True. + speaker_labels: Enable speaker diarization. When enabled, final transcripts + (end_of_turn=True) include a speaker field identifying the speaker + (e.g., "Speaker A", "Speaker B"). Defaults to None (not sent). """ sample_rate: int = 16000 @@ -120,6 +151,10 @@ class AssemblyAIConnectionParams(BaseModel): min_end_of_turn_silence_when_confident: Optional[int] = None max_turn_silence: Optional[int] = None keyterms_prompt: Optional[List[str]] = None - speech_model: Literal["universal-streaming-english", "universal-streaming-multilingual"] = ( - "universal-streaming-english" + prompt: Optional[str] = None + speech_model: Literal["universal-streaming-english", "universal-streaming-multilingual", "u3-rt-pro"] = ( + "u3-rt-pro" ) + language_detection: Optional[bool] = None + format_turns: bool = True + speaker_labels: Optional[bool] = None diff --git a/src/pipecat/services/assemblyai/stt.py b/src/pipecat/services/assemblyai/stt.py index a89f5fe52..edd5ac7b7 100644 --- a/src/pipecat/services/assemblyai/stt.py +++ b/src/pipecat/services/assemblyai/stt.py @@ -13,7 +13,7 @@ WebSocket API for streaming audio transcription. import asyncio import json from dataclasses import dataclass, field -from typing import Any, AsyncGenerator, Dict, Optional +from typing import Any, AsyncGenerator, Dict, Mapping, Optional from urllib.parse import urlencode from loguru import logger @@ -41,6 +41,7 @@ from .models import ( AssemblyAIConnectionParams, BaseMessage, BeginMessage, + SpeechStartedMessage, TerminationMessage, TurnMessage, ) @@ -54,6 +55,26 @@ except ModuleNotFoundError as e: raise Exception(f"Missing module: {e}") +def map_language_from_assemblyai(language_code: str) -> Language: + """Map AssemblyAI language codes to Pipecat Language enum. + + AssemblyAI returns simple language codes like "es", "fr", etc. + This function maps them to the corresponding Language enum values. + + Args: + language_code: AssemblyAI language code (e.g., "es", "fr", "de") + + Returns: + Corresponding Language enum value, defaulting to Language.EN if not found. + """ + try: + # Try to match the language code directly + return Language(language_code.lower()) + except ValueError: + logger.warning(f"Unknown language code from AssemblyAI: {language_code}, defaulting to English") + return Language.EN + + @dataclass class AssemblyAISTTSettings(STTSettings): """Settings for the AssemblyAI STT service. @@ -87,6 +108,8 @@ class AssemblyAISTTService(WebsocketSTTService): api_endpoint_base_url: str = "wss://streaming.assemblyai.com/v3/ws", connection_params: AssemblyAIConnectionParams = AssemblyAIConnectionParams(), vad_force_turn_endpoint: bool = True, + should_interrupt: bool = True, + speaker_format: Optional[str] = None, ttfs_p99_latency: Optional[float] = ASSEMBLYAI_TTFS_P99, **kwargs, ): @@ -97,18 +120,64 @@ class AssemblyAISTTService(WebsocketSTTService): language: Language code for transcription. Defaults to English (Language.EN). api_endpoint_base_url: WebSocket endpoint URL. Defaults to AssemblyAI's streaming endpoint. connection_params: Connection configuration parameters. Defaults to AssemblyAIConnectionParams(). - vad_force_turn_endpoint: Whether to force turn endpoint on VAD stop. When True, - disables AssemblyAI's model-based turn detection and relies on external VAD - to trigger turn endpoints. Automatically sets end_of_turn_confidence_threshold=1.0 - and max_turn_silence=2000 unless explicitly overridden. Defaults to True. + vad_force_turn_endpoint: Controls turn detection mode. + When True (Pipecat mode, default): Forces AssemblyAI to return finals ASAP + so Pipecat's turn detection (e.g., Smart Turn) decides when the user is done. + - min_end_of_turn_silence_when_confident defaults to 100ms (user can override) + - max_turn_silence is ALWAYS set equal to min_end_of_turn_silence_when_confident + - VAD stop sends ForceEndpoint as ceiling + - No UserStarted/StoppedSpeakingFrame emitted from STT + When False (STT mode, u3-rt-pro only): AssemblyAI's model controls turn endings. + - Uses AssemblyAI API defaults for all parameters (unless user explicitly sets them) + - Respects all user-provided connection_params as-is + - Emits UserStarted/StoppedSpeakingFrame from STT + - No ForceEndpoint on VAD stop + should_interrupt: Whether to interrupt the bot when the user starts speaking + in STT mode (vad_force_turn_endpoint=False). Only applies to STT mode. + Defaults to True. + speaker_format: Optional format string for speaker labels when diarization is enabled. + Use {speaker} for speaker label and {text} for transcript text. + Example: "<{speaker}>{text}" or "{speaker}: {text}" + If None, transcript text is not modified. Defaults to None. ttfs_p99_latency: P99 latency from speech end to final transcript in seconds. Override for your deployment. See https://github.com/pipecat-ai/stt-benchmark **kwargs: Additional arguments passed to parent STTService class. """ - # When vad_force_turn_endpoint is enabled, configure connection params for manual - # turn detection mode (disable model-based turn detection) + # STT turn detection (vad_force_turn_endpoint=False) requires the + # SpeechStarted event for reliable barge-in. Only u3-rt-pro supports + # this. Other models must use Pipecat turn detection. + is_u3_pro = connection_params.speech_model == "u3-rt-pro" + if not vad_force_turn_endpoint and not is_u3_pro: + raise ValueError( + f"STT turn detection (vad_force_turn_endpoint=False) requires " + f"u3-rt-pro for SpeechStarted support. Either set " + f"vad_force_turn_endpoint=True for {connection_params.speech_model}, " + f"or use speech_model='u3-rt-pro'." + ) + + # Validate that prompt and keyterms_prompt are not both set + if connection_params.prompt is not None and connection_params.keyterms_prompt is not None: + raise ValueError( + "The prompt and keyterms_prompt parameters cannot be used in the same request. " + "Please choose either one or the other based on your use case. When you use " + "keyterms_prompt, your boosted words are appended to the default prompt automatically. " + "Or to boost within prompt: + Make sure to boost the words in the audio. " + "For more info go to: https://www.assemblyai.com/docs/streaming/universal-3-pro" + ) + + # Warn if user sets a custom prompt (recommend testing without one first) + if connection_params.prompt is not None: + logger.warning( + "Custom prompt detected. We recommend testing with no prompt first, as this " + "will use our optimized default prompt for voice agents. Bad prompts may lead " + "to bad results. If you'd like to create your own prompt, check out our " + "prompting guide at: https://www.assemblyai.com/docs/streaming/prompting" + ) + + # When vad_force_turn_endpoint is enabled, configure connection params + # for Pipecat turn detection mode (fast finals for smart turn analyzer) if vad_force_turn_endpoint: - connection_params = self._configure_manual_turn_mode(connection_params) + connection_params = self._configure_pipecat_turn_mode(connection_params, is_u3_pro) super().__init__( sample_rate=connection_params.sample_rate, @@ -124,6 +193,8 @@ class AssemblyAISTTService(WebsocketSTTService): self._api_key = api_key self._api_endpoint_base_url = api_endpoint_base_url self._vad_force_turn_endpoint = vad_force_turn_endpoint + self._should_interrupt = should_interrupt + self._speaker_format = speaker_format self._termination_event = asyncio.Event() self._received_termination = False @@ -135,45 +206,77 @@ class AssemblyAISTTService(WebsocketSTTService): self._chunk_size_ms = 50 self._chunk_size_bytes = 0 - def _configure_manual_turn_mode( - self, connection_params: AssemblyAIConnectionParams - ) -> AssemblyAIConnectionParams: - """Configure connection params for manual turn detection mode. + self._user_speaking = False + self._vad_speaking = False - When vad_force_turn_endpoint is enabled, we want to disable AssemblyAI's - model-based turn detection and rely on external VAD. This requires: - - end_of_turn_confidence_threshold=1.0 (disable semantic turn detection) - - max_turn_silence=2000 (high value since VAD handles turn endings) + # Log final connection params after any modifications + logger.info(f"{self} Final connection params being sent to AssemblyAI:") + logger.info(f" min_end_of_turn_silence_when_confident: {self._settings.connection_params.min_end_of_turn_silence_when_confident}") + logger.info(f" max_turn_silence: {self._settings.connection_params.max_turn_silence}") + + # Warn if min_end_of_turn_silence_when_confident is not 100ms + if self._settings.connection_params.min_end_of_turn_silence_when_confident != 100: + logger.warning( + f"For best latency, set min_end_of_turn_silence_when_confident to 100ms. " + f"Current value: {self._settings.connection_params.min_end_of_turn_silence_when_confident}ms" + ) + + def _configure_pipecat_turn_mode( + self, connection_params: AssemblyAIConnectionParams, is_u3_pro: bool + ) -> AssemblyAIConnectionParams: + """Configure connection params for Pipecat turn detection mode. + + When vad_force_turn_endpoint is enabled, force AssemblyAI to return + finals as fast as possible so Pipecat's smart turn analyzer can decide + when the user is done speaking. VAD stop is the absolute ceiling. + + u3-rt-pro: + - min_end_of_turn_silence_when_confident defaults to 100ms (user can override) + - max_turn_silence is ALWAYS set equal to min_end_of_turn_silence_when_confident + to avoid double turn detection (AssemblyAI + Pipecat both analyzing) + - If user sets max_turn_silence, it's ignored with a warning + - end_of_turn_confidence_threshold: not set (API default) + + universal-streaming-*: + - end_of_turn_confidence_threshold=0.0 (disable semantic turn detection) + - min_end_of_turn_silence_when_confident=160 + - max_turn_silence: not set (API default) Args: connection_params: The user-provided connection parameters. + is_u3_pro: Whether using u3-rt-pro model. Returns: - Updated connection parameters configured for manual turn mode. + Updated connection parameters configured for Pipecat turn mode. """ updates = {} - # Check end_of_turn_confidence_threshold - if connection_params.end_of_turn_confidence_threshold is None: - updates["end_of_turn_confidence_threshold"] = 1.0 - elif connection_params.end_of_turn_confidence_threshold != 1.0: - logger.warning( - f"vad_force_turn_endpoint is enabled but end_of_turn_confidence_threshold " - f"is set to {connection_params.end_of_turn_confidence_threshold}. " - f"For manual turn detection mode, this should be 1.0 to disable " - f"model-based turn detection. The current value will be used." - ) + if is_u3_pro: + # u3-rt-pro: Synchronize max_turn_silence with min_end_of_turn_silence_when_confident + min_silence = connection_params.min_end_of_turn_silence_when_confident + if min_silence is None: + min_silence = 100 - # Check max_turn_silence - if connection_params.max_turn_silence is None: - updates["max_turn_silence"] = 2000 - elif connection_params.max_turn_silence < 1000: - logger.warning( - f"vad_force_turn_endpoint is enabled but max_turn_silence is set to " - f"{connection_params.max_turn_silence}ms. With manual turn detection, " - f"a higher value (e.g., 2000ms) is recommended to avoid premature " - f"turn endings. The current value will be used." - ) + # Warn if user set max_turn_silence (will be overridden) + if connection_params.max_turn_silence is not None: + logger.warning( + f"Your max_turn_silence value ({connection_params.max_turn_silence}ms) will be " + f"OVERRIDDEN in Pipecat mode (vad_force_turn_endpoint=True). It will be set to " + f"{min_silence}ms (matching min_end_of_turn_silence_when_confident) and SENT to " + f"AssemblyAI to avoid double turn detection. To use your max_turn_silence as-is, " + f"switch to STT mode (vad_force_turn_endpoint=False)." + ) + + updates = { + "min_end_of_turn_silence_when_confident": min_silence, + "max_turn_silence": min_silence, + } + else: + # universal-streaming: Different configuration (works differently) + updates = { + "end_of_turn_confidence_threshold": 0.0, + "min_end_of_turn_silence_when_confident": 160, + } # Apply updates if any if updates: @@ -190,9 +293,14 @@ class AssemblyAISTTService(WebsocketSTTService): return True async def _update_settings(self, delta: STTSettings) -> dict[str, Any]: - """Apply a settings delta. + """Apply a settings delta and send UpdateConfiguration if connected. - Settings are stored but not applied to the active connection. + Stores settings changes and sends UpdateConfiguration message to AssemblyAI + without reconnecting. Supports updating: + - keyterms_prompt: List of terms to boost (can be empty array to clear) + - prompt: Custom prompt text (u3-rt-pro only) + - max_turn_silence: Maximum silence before forcing turn end + - min_end_of_turn_silence_when_confident: Silence before EOT check Args: delta: A :class:`STTSettings` (or ``AssemblyAISTTSettings``) delta. @@ -205,18 +313,63 @@ class AssemblyAISTTService(WebsocketSTTService): if not changed: return changed - # TODO: someday we could reconnect here to apply updated settings. - # Code might look something like the below: - # # Re-apply manual turn mode config if vad_force_turn_endpoint is active - # # and connection_params were updated. - # if self._vad_force_turn_endpoint and "connection_params" in changed: - # self._settings.connection_params = self._configure_manual_turn_mode( - # self._settings.connection_params - # ) - # await self._disconnect() - # await self._connect() + # If websocket is connected, send UpdateConfiguration for supported params + if self._websocket and self._websocket.state is State.OPEN and "connection_params" in changed: + # Build UpdateConfiguration message + update_config = {"type": "UpdateConfiguration"} + conn_params = self._settings.connection_params - self._warn_unhandled_updated_settings(changed) + # Get the old connection_params to see what changed + old_conn_params = changed.get("connection_params") + + # Check each potentially changed parameter + if hasattr(conn_params, "keyterms_prompt"): + if old_conn_params is None or conn_params.keyterms_prompt != old_conn_params.keyterms_prompt: + if conn_params.keyterms_prompt is not None: + update_config["keyterms_prompt"] = conn_params.keyterms_prompt + logger.info(f"Updating keyterms_prompt to: {conn_params.keyterms_prompt}") + + if hasattr(conn_params, "prompt"): + if old_conn_params is None or conn_params.prompt != old_conn_params.prompt: + if conn_params.prompt is not None: + if conn_params.speech_model != "u3-rt-pro": + logger.warning( + f"prompt parameter is only supported with u3-rt-pro model, " + f"current model is {conn_params.speech_model}" + ) + else: + update_config["prompt"] = conn_params.prompt + logger.info(f"Updating prompt") + + if hasattr(conn_params, "max_turn_silence"): + if old_conn_params is None or conn_params.max_turn_silence != old_conn_params.max_turn_silence: + if conn_params.max_turn_silence is not None: + update_config["max_turn_silence"] = conn_params.max_turn_silence + logger.info(f"Updating max_turn_silence to: {conn_params.max_turn_silence}ms") + + if hasattr(conn_params, "min_end_of_turn_silence_when_confident"): + if old_conn_params is None or conn_params.min_end_of_turn_silence_when_confident != old_conn_params.min_end_of_turn_silence_when_confident: + if conn_params.min_end_of_turn_silence_when_confident is not None: + update_config["min_end_of_turn_silence_when_confident"] = conn_params.min_end_of_turn_silence_when_confident + logger.info(f"Updating min_end_of_turn_silence_when_confident to: {conn_params.min_end_of_turn_silence_when_confident}ms") + + # Send update if we have parameters to update + if len(update_config) > 1: # More than just "type" + try: + await self._websocket.send(json.dumps(update_config)) + logger.info(f"Sent UpdateConfiguration: {update_config}") + except Exception as e: + logger.error(f"Failed to send UpdateConfiguration: {e}") + elif "connection_params" in changed: + logger.warning( + "Connection params changed but WebSocket not connected. " + "Settings will be applied on next connection." + ) + + # Warn about other settings that can't be changed dynamically + other_changes = {k: v for k, v in changed.items() if k not in ["connection_params"]} + if other_changes: + self._warn_unhandled_updated_settings(other_changes) return changed @@ -305,7 +458,13 @@ class AssemblyAISTTService(WebsocketSTTService): if params: query_string = urlencode(params) - return f"{self._api_endpoint_base_url}?{query_string}" + full_url = f"{self._api_endpoint_base_url}?{query_string}" + logger.info(f"{self} WebSocket URL being sent to AssemblyAI:") + logger.info(f" {full_url}") + logger.info(f" Parsed params:") + for k, v in params.items(): + logger.info(f" {k}: {v}") + return full_url return self._api_endpoint_base_url async def _connect(self): @@ -421,6 +580,9 @@ class AssemblyAISTTService(WebsocketSTTService): async for message in self._get_websocket(): try: data = json.loads(message) + # Log raw JSON for Turn messages to debug speaker_label + if data.get("type") == "Turn": + logger.debug(f"{self} RAW JSON from AssemblyAI: {json.dumps(data, indent=2)}") await self._handle_message(data) except json.JSONDecodeError: logger.warning(f"Received non-JSON message: {message}") @@ -433,6 +595,8 @@ class AssemblyAISTTService(WebsocketSTTService): return BeginMessage.model_validate(message) elif msg_type == "Turn": return TurnMessage.model_validate(message) + elif msg_type == "SpeechStarted": + return SpeechStartedMessage.model_validate(message) elif msg_type == "Termination": return TerminationMessage.model_validate(message) else: @@ -449,11 +613,37 @@ class AssemblyAISTTService(WebsocketSTTService): ) elif isinstance(parsed_message, TurnMessage): await self._handle_transcription(parsed_message) + elif isinstance(parsed_message, SpeechStartedMessage): + await self._handle_speech_started(parsed_message) elif isinstance(parsed_message, TerminationMessage): await self._handle_termination(parsed_message) except Exception as e: await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e) + async def _handle_speech_started(self, message: SpeechStartedMessage): + """Handle SpeechStarted event — fast barge-in for Mode 2. + + Broadcasts UserStartedSpeakingFrame to signal the start of user + speech, then pushes an interruption to cancel any bot audio. + SpeechStarted fires before any transcript arrives, so the turn + is cleanly started before any transcription frames are pushed. + + Only applies to Mode 2 (STT turn detection). In Mode 1, VAD + + smart turn analyzer handle interruptions via the aggregator. + """ + logger.debug(f"{self} SpeechStarted received (vad_force_turn_endpoint={self._vad_force_turn_endpoint})") + if self._vad_force_turn_endpoint: + logger.debug(f"{self} SpeechStarted ignored in Pipecat mode") + return # Mode 1: handled by aggregator + + logger.debug(f"{self} Processing SpeechStarted in STT mode") + await self.start_processing_metrics() + await self.broadcast_frame(UserStartedSpeakingFrame) + if self._should_interrupt: + await self.push_interruption_task_frame_and_wait() + self._user_speaking = True + logger.debug(f"{self} _user_speaking set to True") + async def _handle_termination(self, message: TerminationMessage): """Handle termination message.""" self._received_termination = True @@ -466,30 +656,135 @@ class AssemblyAISTTService(WebsocketSTTService): await self.push_frame(EndFrame()) async def _handle_transcription(self, message: TurnMessage): - """Handle transcription results.""" + """Handle transcription results with two-mode turn detection. + + Mode 1 (vad_force_turn_endpoint=True, Pipecat turn detection): + - No UserStarted/StoppedSpeakingFrame from STT + - end_of_turn → TranscriptionFrame (finalized set by base class + if this is a ForceEndpoint response) + - else → InterimTranscriptionFrame + + Mode 2 (vad_force_turn_endpoint=False, STT turn detection): + - UserStartedSpeakingFrame on first transcript + - end_of_turn → TranscriptionFrame + UserStoppedSpeakingFrame + - else → InterimTranscriptionFrame + """ + # Log transcript details + logger.info(f"{self} ===== TRANSCRIPT RECEIVED =====") + logger.info(f" Text: \"{message.transcript}\"") + logger.info(f" end_of_turn: {message.end_of_turn}") + logger.info(f" turn_is_formatted: {message.turn_is_formatted}") + logger.info(f" turn_order: {message.turn_order}") + if message.end_of_turn_confidence is not None: + logger.info(f" end_of_turn_confidence: {message.end_of_turn_confidence}") + logger.info(f" speaker: {message.speaker}") + logger.info(f"===============================") + if not message.transcript: return - if message.end_of_turn and ( - not self._settings.connection_params.formatted_finals or message.turn_is_formatted - ): - await self.push_frame( - TranscriptionFrame( - message.transcript, - self._user_id, - time_now_iso8601(), - self._settings.language, - message, + + # Use detected language if available with sufficient confidence + language = Language.EN + if message.language_code and message.language_confidence: + if message.language_confidence >= 0.7: + language = map_language_from_assemblyai(message.language_code) + else: + logger.warning( + f"Low language detection confidence ({message.language_confidence:.2f}) " + f"for language '{message.language_code}', falling back to English" ) - ) - await self._trace_transcription(message.transcript, True, self._settings.language) - await self.stop_processing_metrics() + + # Handle speaker diarization + speaker_id = self._user_id + transcript_text = message.transcript + + if message.speaker: + speaker_id = message.speaker + # Format transcript with speaker labels if format string provided + if self._speaker_format: + transcript_text = self._speaker_format.format( + speaker=message.speaker, + text=message.transcript + ) + logger.info(f"{self} 🤖 TEXT SENT TO LLM (with speaker format): \"{transcript_text}\"") + else: + logger.info(f"{self} 🤖 TEXT SENT TO LLM (speaker {message.speaker}): \"{transcript_text}\"") else: - await self.push_frame( - InterimTranscriptionFrame( - message.transcript, - self._user_id, - time_now_iso8601(), - self._settings.language, - message, + logger.info(f"{self} 🤖 TEXT SENT TO LLM: \"{transcript_text}\"") + + # Determine if this is a final turn from AssemblyAI + is_final_turn = message.end_of_turn and ( + not self._settings.connection_params.format_turns or message.turn_is_formatted + ) + + if self._vad_force_turn_endpoint: + # --- Mode 1: Pipecat turn detection --- + # No UserStarted/StoppedSpeakingFrame — VAD + smart turn analyzer handle this + if is_final_turn: + finalize_confirmed = bool(message.turn_is_formatted) + if finalize_confirmed: + self.confirm_finalize() + logger.debug(f"{self} Final transcript: \"{transcript_text}\"") + await self.push_frame( + TranscriptionFrame( + transcript_text, + speaker_id, + time_now_iso8601(), + language, + message, + ) + ) + await self._trace_transcription(transcript_text, True, language) + await self.stop_processing_metrics() + else: + logger.debug(f"{self} Interim transcript: \"{transcript_text}\"") + await self.push_frame( + InterimTranscriptionFrame( + transcript_text, + speaker_id, + time_now_iso8601(), + language, + message, + ) + ) + else: + # --- Mode 2: STT turn detection --- + # SpeechStarted handles UserStartedSpeakingFrame + interruption. + # If SpeechStarted hasn't fired yet (shouldn't happen, but guard), + # broadcast here as fallback. + logger.debug(f"{self} Transcript received in STT mode (_user_speaking={self._user_speaking})") + if not self._user_speaking: + logger.warning(f"{self} Transcript arrived before SpeechStarted, broadcasting fallback UserStartedSpeakingFrame") + await self.broadcast_frame(UserStartedSpeakingFrame) + self._user_speaking = True + + if is_final_turn: + if message.turn_is_formatted: + self.confirm_finalize() + await self.push_frame( + TranscriptionFrame( + transcript_text, + speaker_id, + time_now_iso8601(), + language, + message, + finalized=True, + ) + ) + await self._trace_transcription(transcript_text, True, language) + await self.stop_processing_metrics() + # AAI is authoritative — emit UserStoppedSpeakingFrame immediately. + # broadcast_frame pushes downstream (same queue as TranscriptionFrame + # above, so ordering is preserved) and upstream. + await self.broadcast_frame(UserStoppedSpeakingFrame) + self._user_speaking = False + else: + await self.push_frame( + InterimTranscriptionFrame( + transcript_text, + speaker_id, + time_now_iso8601(), + language, + message, + ) ) - ) From cd07937c5dcc413000ede5bbc8a232f477a6cd25 Mon Sep 17 00:00:00 2001 From: zack Date: Thu, 26 Feb 2026 22:18:02 -0500 Subject: [PATCH 05/68] Fix missing imports: Add UserStartedSpeakingFrame and UserStoppedSpeakingFrame --- src/pipecat/services/assemblyai/stt.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/pipecat/services/assemblyai/stt.py b/src/pipecat/services/assemblyai/stt.py index edd5ac7b7..9881c145f 100644 --- a/src/pipecat/services/assemblyai/stt.py +++ b/src/pipecat/services/assemblyai/stt.py @@ -26,6 +26,8 @@ from pipecat.frames.frames import ( InterimTranscriptionFrame, StartFrame, TranscriptionFrame, + UserStartedSpeakingFrame, + UserStoppedSpeakingFrame, VADUserStartedSpeakingFrame, VADUserStoppedSpeakingFrame, ) From aa7e9a17d5ecdb3d934833e65dae56570bd8c0ca Mon Sep 17 00:00:00 2001 From: zack Date: Fri, 27 Feb 2026 14:55:22 -0500 Subject: [PATCH 06/68] Fix finalization pattern: Use request/confirm in Pipecat mode, finalized flag in STT mode - Add request_finalize() before sending ForceEndpoint in Pipecat mode - Keep confirm_finalize() when receiving formatted finals in Pipecat mode - Remove confirm_finalize() from STT mode (use finalized=True instead) This follows Pipecat's two-step finalization pattern where request_finalize() is called when sending a finalize request to the STT service, and confirm_finalize() is called when receiving confirmation back. --- src/pipecat/services/assemblyai/stt.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pipecat/services/assemblyai/stt.py b/src/pipecat/services/assemblyai/stt.py index 9881c145f..90e292ac0 100644 --- a/src/pipecat/services/assemblyai/stt.py +++ b/src/pipecat/services/assemblyai/stt.py @@ -438,6 +438,7 @@ class AssemblyAISTTService(WebsocketSTTService): and self._websocket and self._websocket.state is State.OPEN ): + await self.request_finalize() await self._websocket.send(json.dumps({"type": "ForceEndpoint"})) await self.start_processing_metrics() @@ -761,8 +762,7 @@ class AssemblyAISTTService(WebsocketSTTService): self._user_speaking = True if is_final_turn: - if message.turn_is_formatted: - self.confirm_finalize() + # STT mode: AssemblyAI controls finalization, just mark as finalized await self.push_frame( TranscriptionFrame( transcript_text, From 6ba9f780b08eddbfb6fa2cabe3cce6fe3adc457d Mon Sep 17 00:00:00 2001 From: zack Date: Fri, 27 Feb 2026 15:00:38 -0500 Subject: [PATCH 07/68] Remove unnecessary SpeechStarted fallback in STT mode u3-rt-pro guarantees SpeechStarted is always sent before transcripts, so the fallback UserStartedSpeakingFrame broadcast is never needed. This ensures clean pairing of UserStarted/StoppedSpeakingFrame: - Start: Always from _handle_speech_started - Stop: Always from _handle_transcription on final turn --- src/pipecat/services/assemblyai/stt.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/pipecat/services/assemblyai/stt.py b/src/pipecat/services/assemblyai/stt.py index 90e292ac0..0618a29fe 100644 --- a/src/pipecat/services/assemblyai/stt.py +++ b/src/pipecat/services/assemblyai/stt.py @@ -752,14 +752,9 @@ class AssemblyAISTTService(WebsocketSTTService): ) else: # --- Mode 2: STT turn detection --- - # SpeechStarted handles UserStartedSpeakingFrame + interruption. - # If SpeechStarted hasn't fired yet (shouldn't happen, but guard), - # broadcast here as fallback. + # SpeechStarted always arrives before transcripts with u3-rt-pro, + # so UserStartedSpeakingFrame is guaranteed to be broadcast first. logger.debug(f"{self} Transcript received in STT mode (_user_speaking={self._user_speaking})") - if not self._user_speaking: - logger.warning(f"{self} Transcript arrived before SpeechStarted, broadcasting fallback UserStartedSpeakingFrame") - await self.broadcast_frame(UserStartedSpeakingFrame) - self._user_speaking = True if is_final_turn: # STT mode: AssemblyAI controls finalization, just mark as finalized From 45532a947881817bd1150bd8695a7125b407df04 Mon Sep 17 00:00:00 2001 From: zack Date: Fri, 27 Feb 2026 16:15:49 -0500 Subject: [PATCH 08/68] Remove info logs and unused import per PR feedback - Remove unused Mapping import - Remove info logs at initialization (connection params) - Remove info logs in _handle_transcription (transcript details, text sent to LLM) - Remove info logs in _build_ws_url (WebSocket URL and params) - Keep debug logs (less verbose, appropriate for development) --- src/pipecat/services/assemblyai/stt.py | 38 ++------------------------ 1 file changed, 2 insertions(+), 36 deletions(-) diff --git a/src/pipecat/services/assemblyai/stt.py b/src/pipecat/services/assemblyai/stt.py index 0618a29fe..445ab7b73 100644 --- a/src/pipecat/services/assemblyai/stt.py +++ b/src/pipecat/services/assemblyai/stt.py @@ -13,7 +13,7 @@ WebSocket API for streaming audio transcription. import asyncio import json from dataclasses import dataclass, field -from typing import Any, AsyncGenerator, Dict, Mapping, Optional +from typing import Any, AsyncGenerator, Dict, Optional from urllib.parse import urlencode from loguru import logger @@ -211,18 +211,6 @@ class AssemblyAISTTService(WebsocketSTTService): self._user_speaking = False self._vad_speaking = False - # Log final connection params after any modifications - logger.info(f"{self} Final connection params being sent to AssemblyAI:") - logger.info(f" min_end_of_turn_silence_when_confident: {self._settings.connection_params.min_end_of_turn_silence_when_confident}") - logger.info(f" max_turn_silence: {self._settings.connection_params.max_turn_silence}") - - # Warn if min_end_of_turn_silence_when_confident is not 100ms - if self._settings.connection_params.min_end_of_turn_silence_when_confident != 100: - logger.warning( - f"For best latency, set min_end_of_turn_silence_when_confident to 100ms. " - f"Current value: {self._settings.connection_params.min_end_of_turn_silence_when_confident}ms" - ) - def _configure_pipecat_turn_mode( self, connection_params: AssemblyAIConnectionParams, is_u3_pro: bool ) -> AssemblyAIConnectionParams: @@ -461,13 +449,7 @@ class AssemblyAISTTService(WebsocketSTTService): if params: query_string = urlencode(params) - full_url = f"{self._api_endpoint_base_url}?{query_string}" - logger.info(f"{self} WebSocket URL being sent to AssemblyAI:") - logger.info(f" {full_url}") - logger.info(f" Parsed params:") - for k, v in params.items(): - logger.info(f" {k}: {v}") - return full_url + return f"{self._api_endpoint_base_url}?{query_string}" return self._api_endpoint_base_url async def _connect(self): @@ -672,17 +654,6 @@ class AssemblyAISTTService(WebsocketSTTService): - end_of_turn → TranscriptionFrame + UserStoppedSpeakingFrame - else → InterimTranscriptionFrame """ - # Log transcript details - logger.info(f"{self} ===== TRANSCRIPT RECEIVED =====") - logger.info(f" Text: \"{message.transcript}\"") - logger.info(f" end_of_turn: {message.end_of_turn}") - logger.info(f" turn_is_formatted: {message.turn_is_formatted}") - logger.info(f" turn_order: {message.turn_order}") - if message.end_of_turn_confidence is not None: - logger.info(f" end_of_turn_confidence: {message.end_of_turn_confidence}") - logger.info(f" speaker: {message.speaker}") - logger.info(f"===============================") - if not message.transcript: return @@ -709,11 +680,6 @@ class AssemblyAISTTService(WebsocketSTTService): speaker=message.speaker, text=message.transcript ) - logger.info(f"{self} 🤖 TEXT SENT TO LLM (with speaker format): \"{transcript_text}\"") - else: - logger.info(f"{self} 🤖 TEXT SENT TO LLM (speaker {message.speaker}): \"{transcript_text}\"") - else: - logger.info(f"{self} 🤖 TEXT SENT TO LLM: \"{transcript_text}\"") # Determine if this is a final turn from AssemblyAI is_final_turn = message.end_of_turn and ( From ef00f27d53a1733e5f94ca31b442edbee102d6f3 Mon Sep 17 00:00:00 2001 From: zack Date: Fri, 27 Feb 2026 17:58:05 -0500 Subject: [PATCH 09/68] Fix incorrect await on synchronous request_finalize() method The request_finalize() method in STTService is synchronous (sets a flag), but was being called with await in the VAD turn endpoint handling code. This caused "object NoneType can't be used in 'await' expression" errors. Also includes automatic formatting improvements from ruff. --- src/pipecat/services/assemblyai/models.py | 6 +-- src/pipecat/services/assemblyai/stt.py | 55 ++++++++++++++++------- 2 files changed, 43 insertions(+), 18 deletions(-) diff --git a/src/pipecat/services/assemblyai/models.py b/src/pipecat/services/assemblyai/models.py index fb883ac99..949f2e00e 100644 --- a/src/pipecat/services/assemblyai/models.py +++ b/src/pipecat/services/assemblyai/models.py @@ -152,9 +152,9 @@ class AssemblyAIConnectionParams(BaseModel): max_turn_silence: Optional[int] = None keyterms_prompt: Optional[List[str]] = None prompt: Optional[str] = None - speech_model: Literal["universal-streaming-english", "universal-streaming-multilingual", "u3-rt-pro"] = ( - "u3-rt-pro" - ) + speech_model: Literal[ + "universal-streaming-english", "universal-streaming-multilingual", "u3-rt-pro" + ] = "u3-rt-pro" language_detection: Optional[bool] = None format_turns: bool = True speaker_labels: Optional[bool] = None diff --git a/src/pipecat/services/assemblyai/stt.py b/src/pipecat/services/assemblyai/stt.py index 445ab7b73..b0254d410 100644 --- a/src/pipecat/services/assemblyai/stt.py +++ b/src/pipecat/services/assemblyai/stt.py @@ -73,7 +73,9 @@ def map_language_from_assemblyai(language_code: str) -> Language: # Try to match the language code directly return Language(language_code.lower()) except ValueError: - logger.warning(f"Unknown language code from AssemblyAI: {language_code}, defaulting to English") + logger.warning( + f"Unknown language code from AssemblyAI: {language_code}, defaulting to English" + ) return Language.EN @@ -304,7 +306,11 @@ class AssemblyAISTTService(WebsocketSTTService): return changed # If websocket is connected, send UpdateConfiguration for supported params - if self._websocket and self._websocket.state is State.OPEN and "connection_params" in changed: + if ( + self._websocket + and self._websocket.state is State.OPEN + and "connection_params" in changed + ): # Build UpdateConfiguration message update_config = {"type": "UpdateConfiguration"} conn_params = self._settings.connection_params @@ -314,7 +320,10 @@ class AssemblyAISTTService(WebsocketSTTService): # Check each potentially changed parameter if hasattr(conn_params, "keyterms_prompt"): - if old_conn_params is None or conn_params.keyterms_prompt != old_conn_params.keyterms_prompt: + if ( + old_conn_params is None + or conn_params.keyterms_prompt != old_conn_params.keyterms_prompt + ): if conn_params.keyterms_prompt is not None: update_config["keyterms_prompt"] = conn_params.keyterms_prompt logger.info(f"Updating keyterms_prompt to: {conn_params.keyterms_prompt}") @@ -332,16 +341,29 @@ class AssemblyAISTTService(WebsocketSTTService): logger.info(f"Updating prompt") if hasattr(conn_params, "max_turn_silence"): - if old_conn_params is None or conn_params.max_turn_silence != old_conn_params.max_turn_silence: + if ( + old_conn_params is None + or conn_params.max_turn_silence != old_conn_params.max_turn_silence + ): if conn_params.max_turn_silence is not None: update_config["max_turn_silence"] = conn_params.max_turn_silence - logger.info(f"Updating max_turn_silence to: {conn_params.max_turn_silence}ms") + logger.info( + f"Updating max_turn_silence to: {conn_params.max_turn_silence}ms" + ) if hasattr(conn_params, "min_end_of_turn_silence_when_confident"): - if old_conn_params is None or conn_params.min_end_of_turn_silence_when_confident != old_conn_params.min_end_of_turn_silence_when_confident: + if ( + old_conn_params is None + or conn_params.min_end_of_turn_silence_when_confident + != old_conn_params.min_end_of_turn_silence_when_confident + ): if conn_params.min_end_of_turn_silence_when_confident is not None: - update_config["min_end_of_turn_silence_when_confident"] = conn_params.min_end_of_turn_silence_when_confident - logger.info(f"Updating min_end_of_turn_silence_when_confident to: {conn_params.min_end_of_turn_silence_when_confident}ms") + update_config["min_end_of_turn_silence_when_confident"] = ( + conn_params.min_end_of_turn_silence_when_confident + ) + logger.info( + f"Updating min_end_of_turn_silence_when_confident to: {conn_params.min_end_of_turn_silence_when_confident}ms" + ) # Send update if we have parameters to update if len(update_config) > 1: # More than just "type" @@ -426,7 +448,7 @@ class AssemblyAISTTService(WebsocketSTTService): and self._websocket and self._websocket.state is State.OPEN ): - await self.request_finalize() + self.request_finalize() await self._websocket.send(json.dumps({"type": "ForceEndpoint"})) await self.start_processing_metrics() @@ -616,7 +638,9 @@ class AssemblyAISTTService(WebsocketSTTService): Only applies to Mode 2 (STT turn detection). In Mode 1, VAD + smart turn analyzer handle interruptions via the aggregator. """ - logger.debug(f"{self} SpeechStarted received (vad_force_turn_endpoint={self._vad_force_turn_endpoint})") + logger.debug( + f"{self} SpeechStarted received (vad_force_turn_endpoint={self._vad_force_turn_endpoint})" + ) if self._vad_force_turn_endpoint: logger.debug(f"{self} SpeechStarted ignored in Pipecat mode") return # Mode 1: handled by aggregator @@ -677,8 +701,7 @@ class AssemblyAISTTService(WebsocketSTTService): # Format transcript with speaker labels if format string provided if self._speaker_format: transcript_text = self._speaker_format.format( - speaker=message.speaker, - text=message.transcript + speaker=message.speaker, text=message.transcript ) # Determine if this is a final turn from AssemblyAI @@ -693,7 +716,7 @@ class AssemblyAISTTService(WebsocketSTTService): finalize_confirmed = bool(message.turn_is_formatted) if finalize_confirmed: self.confirm_finalize() - logger.debug(f"{self} Final transcript: \"{transcript_text}\"") + logger.debug(f'{self} Final transcript: "{transcript_text}"') await self.push_frame( TranscriptionFrame( transcript_text, @@ -706,7 +729,7 @@ class AssemblyAISTTService(WebsocketSTTService): await self._trace_transcription(transcript_text, True, language) await self.stop_processing_metrics() else: - logger.debug(f"{self} Interim transcript: \"{transcript_text}\"") + logger.debug(f'{self} Interim transcript: "{transcript_text}"') await self.push_frame( InterimTranscriptionFrame( transcript_text, @@ -720,7 +743,9 @@ class AssemblyAISTTService(WebsocketSTTService): # --- Mode 2: STT turn detection --- # SpeechStarted always arrives before transcripts with u3-rt-pro, # so UserStartedSpeakingFrame is guaranteed to be broadcast first. - logger.debug(f"{self} Transcript received in STT mode (_user_speaking={self._user_speaking})") + logger.debug( + f"{self} Transcript received in STT mode (_user_speaking={self._user_speaking})" + ) if is_final_turn: # STT mode: AssemblyAI controls finalization, just mark as finalized From d7ce1eedd956ea320d7fb980172494cb1aa8c3e2 Mon Sep 17 00:00:00 2001 From: zack Date: Fri, 27 Feb 2026 17:58:18 -0500 Subject: [PATCH 10/68] Add foundational examples for AssemblyAI u3-rt-pro - 07o-interruptible-assemblyai.py: Basic example using Pipecat VAD mode - 07o-interruptible-assemblyai-stt.py: Advanced example using STT-controlled turn detection with comprehensive documentation on u3-rt-pro features (turn detection tuning, prompt-based enhancement, speaker diarization) --- .../07o-interruptible-assemblyai-stt.py | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 examples/foundational/07o-interruptible-assemblyai-stt.py diff --git a/examples/foundational/07o-interruptible-assemblyai-stt.py b/examples/foundational/07o-interruptible-assemblyai-stt.py new file mode 100644 index 000000000..5deaa82a1 --- /dev/null +++ b/examples/foundational/07o-interruptible-assemblyai-stt.py @@ -0,0 +1,175 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + + +import os + +from dotenv import load_dotenv +from loguru import logger + +from pipecat.frames.frames import LLMRunFrame +from pipecat.pipeline.pipeline import Pipeline +from pipecat.pipeline.runner import PipelineRunner +from pipecat.pipeline.task import PipelineParams, PipelineTask +from pipecat.processors.aggregators.llm_context import LLMContext +from pipecat.processors.aggregators.llm_response_universal import ( + LLMContextAggregatorPair, + LLMUserAggregatorParams, +) +from pipecat.runner.types import RunnerArguments +from pipecat.runner.utils import create_transport +from pipecat.services.assemblyai.models import AssemblyAIConnectionParams +from pipecat.services.assemblyai.stt import AssemblyAISTTService +from pipecat.services.cartesia.tts import CartesiaTTSService +from pipecat.services.openai.llm import OpenAILLMService +from pipecat.transports.base_transport import BaseTransport, TransportParams +from pipecat.transports.daily.transport import DailyParams +from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams +from pipecat.turns.user_turn_strategies import ExternalUserTurnStrategies + +load_dotenv(override=True) + + +# We use lambdas to defer transport parameter creation until the transport +# type is selected at runtime. +transport_params = { + "daily": lambda: DailyParams( + audio_in_enabled=True, + audio_out_enabled=True, + ), + "twilio": lambda: FastAPIWebsocketParams( + audio_in_enabled=True, + audio_out_enabled=True, + ), + "webrtc": lambda: TransportParams( + audio_in_enabled=True, + audio_out_enabled=True, + ), +} + + +async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): + """AssemblyAI u3-rt-pro STT Example with STT-Controlled Turn Detection + + This example demonstrates using AssemblyAI's u3-rt-pro Speech-to-Text model + with STT-controlled turn detection for more natural conversation flow. + + Key features: + + 1. STT-Controlled Turn Detection + - Set `vad_force_turn_endpoint=False` to enable STT mode + - AssemblyAI's model determines when user starts/stops speaking + - Uses `ExternalUserTurnStrategies` instead of Pipecat's VAD + - More natural turn detection based on speech patterns and pauses + + 2. Advanced Turn Detection Tuning (STT Mode) + - `min_end_of_turn_silence_when_confident`: Minimum silence (ms) when confident + about end-of-turn. Lower values = faster responses. Default: 200ms + - `max_turn_silence`: Maximum silence (ms) before forcing end-of-turn. + Prevents long pauses. Default: 1000ms + + 3. Prompt-Based Transcription Enhancement + - Use `prompt` parameter to improve accuracy for specific names/terms + - Particularly useful for proper nouns, technical terms, domain vocabulary + - Example: "Names: Xiomara, Saoirse, Krzystof. Technical terms: API, OAuth." + + 4. Speaker Diarization (Optional) + - Enable with `speaker_labels=True` + - Automatically identifies different speakers in multi-party conversations + - TranscriptionFrame includes speaker_id field (e.g., "Speaker A", "Speaker B") + + 5. Language Detection (Optional, multilingual model only) + - Enable with `language_detection=True` + - Automatically detects spoken language + - Available with universal-streaming-multilingual model + + For more information: https://www.assemblyai.com/docs/speech-to-text/streaming + """ + logger.info(f"Starting bot") + + stt = AssemblyAISTTService( + api_key=os.getenv("ASSEMBLYAI_API_KEY"), + vad_force_turn_endpoint=False, # Enable STT-controlled turn detection + connection_params=AssemblyAIConnectionParams( + speech_model="u3-rt-pro", + # Optional: Tune turn detection timing (defaults shown below) + # min_end_of_turn_silence_when_confident=100, # Default + # max_turn_silence=1000, # Default + # Optional: Boost accuracy for specific names/terms + # prompt="Names: Xiomara, Saoirse, Krzystof. Technical terms: API, OAuth.", + # Optional: Enable speaker diarization + # speaker_labels=True, + ), + ) + + tts = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ) + + llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) + + messages = [ + { + "role": "system", + "content": "You are a helpful LLM in a WebRTC call. Your goal is to demonstrate your capabilities in a succinct way. Your output will be spoken aloud, so avoid special characters that can't easily be spoken, such as emojis or bullet points. Respond to what the user said in a creative and helpful way.", + }, + ] + + context = LLMContext(messages) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams(user_turn_strategies=ExternalUserTurnStrategies()), + ) + + pipeline = Pipeline( + [ + transport.input(), # Transport user input + stt, # STT + user_aggregator, # User responses + llm, # LLM + tts, # TTS + transport.output(), # Transport bot output + assistant_aggregator, # Assistant spoken responses + ] + ) + + task = PipelineTask( + pipeline, + params=PipelineParams( + enable_metrics=True, + enable_usage_metrics=True, + ), + idle_timeout_secs=runner_args.pipeline_idle_timeout_secs, + ) + + @transport.event_handler("on_client_connected") + async def on_client_connected(transport, client): + logger.info(f"Client connected") + # Kick off the conversation. + messages.append({"role": "system", "content": "Please introduce yourself to the user."}) + await task.queue_frames([LLMRunFrame()]) + + @transport.event_handler("on_client_disconnected") + async def on_client_disconnected(transport, client): + logger.info(f"Client disconnected") + await task.cancel() + + runner = PipelineRunner(handle_sigint=runner_args.handle_sigint) + + await runner.run(task) + + +async def bot(runner_args: RunnerArguments): + """Main bot entry point compatible with Pipecat Cloud.""" + transport = await create_transport(runner_args, transport_params) + await run_bot(transport, runner_args) + + +if __name__ == "__main__": + from pipecat.runner.run import main + + main() From 21a409e447fd23b984e4c4da7e1171c510d0d86f Mon Sep 17 00:00:00 2001 From: zack Date: Sun, 1 Mar 2026 11:17:39 -0500 Subject: [PATCH 11/68] Update prompt warning and rename min_end_of_turn_silence_when_confident to min_turn_silence - Add "beta feature" note to custom prompt warning - Rename min_end_of_turn_silence_when_confident parameter to min_turn_silence across all AssemblyAI code - Update documentation, examples, and test files to use new parameter name --- TESTING_CHECKLIST.md | 273 +++++++ TESTING_SETUP.md | 310 ++++++++ .../07o-interruptible-assemblyai-stt.py | 4 +- src/pipecat/services/assemblyai/models.py | 4 +- src/pipecat/services/assemblyai/stt.py | 45 +- test_assemblyai_custom.py | 256 ++++++ test_assemblyai_interactive.py | 750 ++++++++++++++++++ test_assemblyai_u3pro.py | 589 ++++++++++++++ 8 files changed, 2205 insertions(+), 26 deletions(-) create mode 100644 TESTING_CHECKLIST.md create mode 100644 TESTING_SETUP.md create mode 100755 test_assemblyai_custom.py create mode 100755 test_assemblyai_interactive.py create mode 100644 test_assemblyai_u3pro.py diff --git a/TESTING_CHECKLIST.md b/TESTING_CHECKLIST.md new file mode 100644 index 000000000..8d2d147d4 --- /dev/null +++ b/TESTING_CHECKLIST.md @@ -0,0 +1,273 @@ +# AssemblyAI u3-rt-pro Testing Checklist + +## Test Environment Setup +- [ ] Install dependencies: `uv sync --group dev --all-extras` +- [ ] Set up `.env` file with API keys +- [ ] Verify LiveKit connection +- [ ] Run basic voice agent test + +--- + +## Feature Testing Checklist + +### ✅ Basic Configuration Tests + +#### Test 1: Default u3-rt-pro Configuration +- [ ] **Setup:** Create service with default params +- [ ] **Expected:** No errors, uses u3-rt-pro model with 100ms min/max +- [ ] **Verify:** Check logs for connection confirmation + +#### Test 2: Custom min_turn_silence +- [ ] **Setup:** Set `min_turn_silence=200` +- [ ] **Expected:** Both min and max set to 200ms +- [ ] **Verify:** Speak short phrases, observe turn detection timing + +#### Test 3: User sets max_turn_silence (Warning Test) +- [ ] **Setup:** Set `max_turn_silence=500` in connection params +- [ ] **Expected:** Warning logged, value overridden to match min +- [ ] **Verify:** Check logs for warning message + +--- + +### ✅ Prompting Tests + +#### Test 4: No Prompt (Default - Recommended) +- [ ] **Setup:** Don't set prompt parameter +- [ ] **Expected:** Uses default prompt, 88% accuracy, no warnings +- [ ] **Verify:** Transcription quality is good + +#### Test 5: Custom Prompt (Warning Test) +- [ ] **Setup:** Set custom prompt in connection params +- [ ] **Expected:** Warning logged about testing without prompt first +- [ ] **Verify:** Check logs for prompt warning + +#### Test 6: Prompt + Keyterms Conflict (Error Test) +- [ ] **Setup:** Set both `prompt` and `keyterms_prompt` at init +- [ ] **Expected:** ValueError raised with helpful error message +- [ ] **Verify:** Service fails to initialize with clear error + +--- + +### ✅ Keyterms Prompting Tests + +#### Test 7: Basic Keyterms at Init +- [ ] **Setup:** Set `keyterms_prompt=["Pipecat", "AssemblyAI", "Universal-3"]` +- [ ] **Expected:** Terms are boosted in recognition +- [ ] **Verify:** Say the boosted terms, check accuracy + +#### Test 8: Empty Keyterms (No Boosting) +- [ ] **Setup:** Set `keyterms_prompt=[]` +- [ ] **Expected:** No boosting, default behavior +- [ ] **Verify:** Normal transcription + +--- + +### ✅ Diarization Tests + +#### Test 9: Diarization Disabled (Default) +- [ ] **Setup:** Don't set `speaker_labels` parameter +- [ ] **Expected:** No speaker info in transcripts +- [ ] **Verify:** TranscriptionFrame.user_id is default user_id + +#### Test 10: Diarization Enabled (No Formatting) +- [ ] **Setup:** Set `speaker_labels=True` +- [ ] **Expected:** Speaker ID in user_id field, plain text +- [ ] **Verify:** Multiple speakers show different IDs (Speaker A, Speaker B) + +#### Test 11: Diarization with XML Formatting +- [ ] **Setup:** Set `speaker_labels=True`, `speaker_format="<{speaker}>{text}"` +- [ ] **Expected:** Text includes speaker tags: `Hello` +- [ ] **Verify:** Formatted text in transcript, speaker ID in user_id + +#### Test 12: Diarization with Colon Prefix +- [ ] **Setup:** Set `speaker_labels=True`, `speaker_format="{speaker}: {text}"` +- [ ] **Expected:** Text includes prefix: `Speaker A: Hello` +- [ ] **Verify:** Formatted text, multiple speakers distinguishable + +--- + +### ✅ Dynamic Updates Tests + +#### Test 13: Dynamic Keyterms Update (Stage 1 → Stage 2) +- [ ] **Setup:** Start with empty keyterms, update mid-conversation +- [ ] **Expected:** New keyterms take effect immediately +- [ ] **Test Steps:** + 1. Start conversation with no keyterms + 2. Send update frame with `keyterms_prompt=["cardiology", "Dr. Smith"]` + 3. Say the new terms +- [ ] **Verify:** Improved recognition after update + +#### Test 14: Clear Keyterms (Reset Context) +- [ ] **Setup:** Start with keyterms, clear them mid-stream +- [ ] **Expected:** Context biasing removed +- [ ] **Test Steps:** + 1. Start with `keyterms_prompt=["test", "words"]` + 2. Send update frame with `keyterms_prompt=[]` +- [ ] **Verify:** No more boosting after clear + +#### Test 15: Dynamic Silence Parameters +- [ ] **Setup:** Update `max_turn_silence` mid-stream +- [ ] **Expected:** Turn detection timing changes +- [ ] **Test Steps:** + 1. Start with default (1200ms) + 2. Update to `max_turn_silence=5000` (for reading numbers) + 3. Pause longer between words + 4. Update back to `max_turn_silence=1200` +- [ ] **Verify:** Longer pauses tolerated when increased + +#### Test 16: Dynamic Prompt Update +- [ ] **Setup:** Update prompt mid-stream +- [ ] **Expected:** New instructions take effect +- [ ] **Test Steps:** + 1. Start with default prompt + 2. Send update with custom prompt +- [ ] **Verify:** Behavior changes according to new prompt + +#### Test 17: Multiple Parameters at Once +- [ ] **Setup:** Update keyterms, max_turn_silence, and min_end_of_turn together +- [ ] **Expected:** All parameters updated in single WebSocket message +- [ ] **Verify:** Check logs for single UpdateConfiguration message + +#### Test 18: Dynamic Update - Prompt + Keyterms Conflict (Error) +- [ ] **Setup:** Try to update both prompt and keyterms_prompt in same update +- [ ] **Expected:** ValueError raised +- [ ] **Verify:** Update fails with clear error message + +--- + +### ✅ Turn Detection Mode Tests + +#### Test 19: Pipecat Mode (vad_force_turn_endpoint=True) - Default +- [ ] **Setup:** Use default settings (Pipecat mode) +- [ ] **Expected:** + - ForceEndpoint sent on VAD stop + - Smart Turn Analyzer makes decisions + - min=max=100ms for u3-rt-pro +- [ ] **Verify:** Fast finals, Smart Turn handles completeness + +#### Test 20: STT Mode (vad_force_turn_endpoint=False) - u3-rt-pro only +- [ ] **Setup:** Set `vad_force_turn_endpoint=False` with u3-rt-pro +- [ ] **Expected:** + - AssemblyAI controls turn endings + - SpeechStarted message triggers interruptions + - UserStarted/StoppedSpeakingFrame emitted +- [ ] **Verify:** Turn detection from AssemblyAI model + +#### Test 21: STT Mode with universal-streaming (Error Test) +- [ ] **Setup:** Set `vad_force_turn_endpoint=False` with universal-streaming +- [ ] **Expected:** ValueError raised (requires u3-rt-pro) +- [ ] **Verify:** Service fails with clear error + +--- + +### ✅ Language Detection Tests (If Multilingual Model) + +#### Test 22: Language Detection Enabled +- [ ] **Setup:** Use `universal-streaming-multilingual` with `language_detection=True` +- [ ] **Expected:** Language codes in transcripts +- [ ] **Verify:** Speak different languages, check language_code field + +#### Test 23: Language Confidence Threshold +- [ ] **Setup:** Enable language detection +- [ ] **Expected:** High confidence (≥0.7) → detected language, Low → fallback to English +- [ ] **Verify:** Check logs for confidence warnings + +--- + +### ✅ Edge Cases & Error Handling + +#### Test 24: WebSocket Disconnect During Update +- [ ] **Setup:** Simulate disconnect, try update +- [ ] **Expected:** Error logged, update queued for reconnection +- [ ] **Verify:** Graceful handling, no crash + +#### Test 25: Invalid Parameter Types +- [ ] **Setup:** Send update with wrong type (e.g., keyterms_prompt as string) +- [ ] **Expected:** Warning logged, parameter skipped +- [ ] **Verify:** Service continues, invalid param ignored + +#### Test 26: Unknown Parameter in Update +- [ ] **Setup:** Send update with unsupported parameter (e.g., `language`) +- [ ] **Expected:** Warning logged about parameter +- [ ] **Verify:** Other valid params still updated + +--- + +### ✅ Integration Tests + +#### Test 27: Full Voice Agent Flow (Multi-Stage) +- [ ] **Setup:** Complete voice agent with stage transitions +- [ ] **Test Steps:** + 1. Greeting stage (general keyterms) + 2. Name collection stage (name keyterms) + 3. Account number stage (number keyterms, longer silence) + 4. Medical info stage (medical keyterms) + 5. Closing stage (goodbye keyterms) +- [ ] **Verify:** Each stage has appropriate keyterms and timing + +#### Test 28: Diarization + Dynamic Updates +- [ ] **Setup:** Enable diarization, update keyterms mid-stream +- [ ] **Expected:** Both features work together +- [ ] **Verify:** Speaker IDs persist, keyterms update correctly + +#### Test 29: Interruption Handling +- [ ] **Setup:** Bot speaking, user interrupts +- [ ] **Expected:** + - Pipecat mode: VAD + Smart Turn handles + - STT mode: SpeechStarted triggers interrupt +- [ ] **Verify:** Bot stops, user speech processed + +--- + +## Testing Results Template + +``` +| Test # | Feature | Status | Notes | +|--------|---------|--------|-------| +| 1 | Default Config | ✅ PASS | | +| 2 | Custom min_silence | ✅ PASS | | +| 3 | max_silence Warning | ✅ PASS | | +| ... | ... | ... | ... | +``` + +--- + +## Expected Outcomes Summary + +### ✅ Should Work (No Errors) +- Default configuration +- Custom min_turn_silence +- Keyterms prompting +- Diarization with/without formatting +- Dynamic updates (one parameter or multiple) +- Pipecat mode turn detection + +### ⚠️ Should Warn (Logs Warning, Continues) +- Custom prompt set at init +- max_turn_silence set (overridden) +- Invalid parameter types in updates +- Language update attempted +- Prompt used with universal-streaming + +### ❌ Should Error (Raises Exception, Stops) +- prompt + keyterms_prompt at init +- prompt + keyterms_prompt in same update +- vad_force_turn_endpoint=False with universal-streaming + +--- + +## Quick Test Commands + +```bash +# Run basic test +python test_assemblyai_u3pro.py --test basic + +# Run specific test +python test_assemblyai_u3pro.py --test diarization + +# Run all tests +python test_assemblyai_u3pro.py --test all + +# Interactive mode +python test_assemblyai_u3pro.py --interactive +``` diff --git a/TESTING_SETUP.md b/TESTING_SETUP.md new file mode 100644 index 000000000..fa1dca462 --- /dev/null +++ b/TESTING_SETUP.md @@ -0,0 +1,310 @@ +# AssemblyAI u3-rt-pro Testing Setup Guide + +## Quick Start + +### 1. Setup Environment + +```bash +# Copy API keys +cp .env.testing .env + +# Install dependencies +uv sync --group dev --all-extras --no-extra gstreamer --no-extra krisp + +# Make test script executable +chmod +x test_assemblyai_u3pro.py +``` + +### 2. Ensure Audio Devices + +Make sure you have: +- **Microphone** enabled and working +- **Speakers/headphones** connected +- Audio permissions granted (macOS will prompt on first run) + +### 3. Run Tests + +```bash +# Run a specific test +python test_assemblyai_u3pro.py --test basic + +# Interactive mode (choose from menu) +python test_assemblyai_u3pro.py --interactive + +# Run all tests sequentially +python test_assemblyai_u3pro.py --test all +``` + +--- + +## Available Tests + +### Basic Configuration Tests +```bash +# Test 1: Default configuration (min=max=100ms) +python test_assemblyai_u3pro.py --test basic + +# Test 2: Custom min_turn_silence +python test_assemblyai_u3pro.py --test custom_min + +# Test 3: max_turn_silence warning (should be overridden) +python test_assemblyai_u3pro.py --test max_warning +``` + +### Prompting Tests +```bash +# Test 5: Custom prompt warning +python test_assemblyai_u3pro.py --test prompt_warning + +# Test 6: Prompt + keyterms conflict (should error) +python test_assemblyai_u3pro.py --test prompt_keyterms_conflict + +# Test 7: Basic keyterms prompting +python test_assemblyai_u3pro.py --test keyterms +``` + +### Diarization Tests +```bash +# Test 10: Diarization without formatting +python test_assemblyai_u3pro.py --test diarization + +# Test 11: Diarization with XML formatting +python test_assemblyai_u3pro.py --test diarization_xml +``` + +### Dynamic Updates Tests +```bash +# Test 13: Dynamic keyterms (multi-stage) +python test_assemblyai_u3pro.py --test dynamic_keyterms + +# Test 15: Dynamic silence parameters +python test_assemblyai_u3pro.py --test dynamic_silence + +# Test 17: Multiple parameters at once +python test_assemblyai_u3pro.py --test multi_param +``` + +--- + +## Test Execution Flow + +### For Each Test: + +1. **Start the test script** + ```bash + python test_assemblyai_u3pro.py --test + ``` + +2. **Wait for "started" message** indicating the bot is ready + +3. **Speak into your microphone** to test - the bot will: + - Transcribe your speech (you'll see `📝 TRANSCRIPTION:` logs) + - Process through the LLM + - Respond with voice through your speakers + +4. **Observe logs** for: + - ✅ Success indicators + - ⚠️ Warning messages + - ❌ Error messages + - 📝 Transcription output + +5. **Verify expected behavior** against checklist + +6. **Stop test** with Ctrl+C + +--- + +## Expected Test Outcomes + +### Should Pass (✅) +- Basic configuration creates service +- Custom parameters are applied +- Keyterms boost recognition +- Diarization shows speaker IDs +- Dynamic updates work without errors + +### Should Warn (⚠️) +Check logs for warnings: +- "We recommend testing at first with no prompt" +- "max_turn_silence is not used in Pipecat mode" +- "Unknown setting for AssemblyAI STT service" + +### Should Error (❌) +Should raise ValueError and fail to start: +- Both prompt and keyterms_prompt set at init +- Both prompt and keyterms_prompt in same update +- vad_force_turn_endpoint=False with universal-streaming + +--- + +## Debugging Tips + +### Check Logs +```bash +# Run with verbose logging +LOGURU_LEVEL=DEBUG python test_assemblyai_u3pro.py --test +``` + +### Common Issues + +**Issue: "WebSocket connection failed"** +- Check ASSEMBLYAI_API_KEY is correct +- Verify network connection +- Check firewall settings + +**Issue: "No audio input/output"** +- Verify microphone permissions (System Preferences → Security & Privacy → Microphone) +- Check default audio devices in System Preferences → Sound +- Test microphone with another app first +- Make sure no other app is using the microphone + +**Issue: "No transcriptions appearing"** +- Verify microphone permissions +- Check audio levels (speak louder or move closer to mic) +- Speak clearly and wait for VAD to detect +- Check if microphone is muted + +**Issue: "Can't hear bot responses"** +- Check speaker/headphone volume +- Verify correct output device is selected +- Check terminal for TTS errors + +**Issue: "Service fails to start"** +- Check all API keys in .env +- Run `uv sync` to ensure dependencies installed +- Check Python version (3.10+) + +--- + +## Manual Testing Checklist + +After running automated tests, manually verify: + +### ✅ Audio Quality +- [ ] Transcriptions are accurate +- [ ] No distortion or dropouts +- [ ] Latency is acceptable + +### ✅ Turn Detection +- [ ] Bot waits for user to finish speaking +- [ ] No premature cutoffs +- [ ] Handles natural pauses correctly + +### ✅ Interruptions +- [ ] Can interrupt bot mid-sentence +- [ ] Interruption is smooth +- [ ] Bot stops speaking immediately + +### ✅ Diarization (if enabled) +- [ ] Multiple speakers detected correctly +- [ ] Speaker IDs consistent +- [ ] Speaker formatting works + +### ✅ Dynamic Updates +- [ ] Keyterms update without disconnection +- [ ] Turn detection timing changes work +- [ ] Updates logged correctly + +--- + +## Test Results Recording + +### Use this template: + +```markdown +## Test Run: YYYY-MM-DD + +| Test # | Test Name | Status | Notes | +|--------|-----------|--------|-------| +| 1 | basic | ✅ PASS | Transcriptions working | +| 2 | custom_min | ✅ PASS | Turn timing changed | +| 3 | max_warning | ✅ PASS | Warning logged | +| 5 | prompt_warning | ✅ PASS | Warning shown | +| 6 | prompt_keyterms_conflict | ✅ PASS | ValueError raised | +| 7 | keyterms | ✅ PASS | Terms boosted | +| 10 | diarization | ✅ PASS | Speaker IDs correct | +| 11 | diarization_xml | ✅ PASS | XML tags shown | +| 13 | dynamic_keyterms | ✅ PASS | Updates worked | +| 15 | dynamic_silence | ✅ PASS | Timing adjusted | +| 17 | multi_param | ✅ PASS | All params updated | + +### Issues Found: +- None + +### Notes: +- All tests passed successfully +- Latency is excellent (sub-300ms) +- Diarization accuracy is good +``` + +--- + +## Advanced Testing + +### Custom Test Scenarios + +Create custom tests by modifying `test_assemblyai_u3pro.py`: + +```python +async def test_my_custom_scenario(): + """My custom test scenario.""" + logger.info("Testing my specific use case") + + connection_params = AssemblyAIConnectionParams( + speech_model="u3-rt-pro", + # Your custom params here + ) + + task, transport = await create_basic_voice_agent(connection_params) + + # Your test logic here + + runner = PipelineRunner() + await runner.run(task) +``` + +### Stress Testing + +Test with: +- Multiple simultaneous speakers +- Long conversations (30+ minutes) +- Rapid speech +- Heavy accents +- Background noise +- Poor network conditions + +--- + +## Reporting Issues + +When reporting issues, include: + +1. **Test name and number** +2. **Full error message and stack trace** +3. **Relevant log output** (use LOGURU_LEVEL=DEBUG) +4. **Configuration used** (connection_params) +5. **Expected vs actual behavior** +6. **Steps to reproduce** + +--- + +## Next Steps + +After testing: + +1. ✅ Mark completed tests in `TESTING_CHECKLIST.md` +2. 📝 Document any issues found +3. 🐛 Create GitHub issues for bugs +4. ✨ Suggest improvements +5. 📊 Share results with team + +--- + +## Contact + +Questions? Issues? +- Check `TESTING_CHECKLIST.md` for detailed test descriptions +- Review logs with `LOGURU_LEVEL=DEBUG` +- Reach out to the team with your findings + +Happy testing! 🎯 diff --git a/examples/foundational/07o-interruptible-assemblyai-stt.py b/examples/foundational/07o-interruptible-assemblyai-stt.py index 5deaa82a1..ee2994c0e 100644 --- a/examples/foundational/07o-interruptible-assemblyai-stt.py +++ b/examples/foundational/07o-interruptible-assemblyai-stt.py @@ -66,7 +66,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): - More natural turn detection based on speech patterns and pauses 2. Advanced Turn Detection Tuning (STT Mode) - - `min_end_of_turn_silence_when_confident`: Minimum silence (ms) when confident + - `min_turn_silence`: Minimum silence (ms) when confident about end-of-turn. Lower values = faster responses. Default: 200ms - `max_turn_silence`: Maximum silence (ms) before forcing end-of-turn. Prevents long pauses. Default: 1000ms @@ -96,7 +96,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): connection_params=AssemblyAIConnectionParams( speech_model="u3-rt-pro", # Optional: Tune turn detection timing (defaults shown below) - # min_end_of_turn_silence_when_confident=100, # Default + # min_turn_silence=100, # Default # max_turn_silence=1000, # Default # Optional: Boost accuracy for specific names/terms # prompt="Names: Xiomara, Saoirse, Krzystof. Technical terms: API, OAuth.", diff --git a/src/pipecat/services/assemblyai/models.py b/src/pipecat/services/assemblyai/models.py index 949f2e00e..f92b1b8bf 100644 --- a/src/pipecat/services/assemblyai/models.py +++ b/src/pipecat/services/assemblyai/models.py @@ -129,7 +129,7 @@ class AssemblyAIConnectionParams(BaseModel): formatted_finals: Whether to enable transcript formatting. Defaults to True. word_finalization_max_wait_time: Maximum time to wait for word finalization in milliseconds. end_of_turn_confidence_threshold: Confidence threshold for end-of-turn detection. - min_end_of_turn_silence_when_confident: Minimum silence duration when confident about end-of-turn. + min_turn_silence: Minimum silence duration when confident about end-of-turn. max_turn_silence: Maximum silence duration before forcing end-of-turn. keyterms_prompt: List of key terms to guide transcription. Will be JSON serialized before sending. prompt: Optional text prompt to guide the transcription. Only used when speech_model is "u3-rt-pro". @@ -148,7 +148,7 @@ class AssemblyAIConnectionParams(BaseModel): formatted_finals: bool = True word_finalization_max_wait_time: Optional[int] = None end_of_turn_confidence_threshold: Optional[float] = None - min_end_of_turn_silence_when_confident: Optional[int] = None + min_turn_silence: Optional[int] = None max_turn_silence: Optional[int] = None keyterms_prompt: Optional[List[str]] = None prompt: Optional[str] = None diff --git a/src/pipecat/services/assemblyai/stt.py b/src/pipecat/services/assemblyai/stt.py index b0254d410..d82b38a99 100644 --- a/src/pipecat/services/assemblyai/stt.py +++ b/src/pipecat/services/assemblyai/stt.py @@ -127,8 +127,8 @@ class AssemblyAISTTService(WebsocketSTTService): vad_force_turn_endpoint: Controls turn detection mode. When True (Pipecat mode, default): Forces AssemblyAI to return finals ASAP so Pipecat's turn detection (e.g., Smart Turn) decides when the user is done. - - min_end_of_turn_silence_when_confident defaults to 100ms (user can override) - - max_turn_silence is ALWAYS set equal to min_end_of_turn_silence_when_confident + - min_turn_silence defaults to 100ms (user can override) + - max_turn_silence is ALWAYS set equal to min_turn_silence - VAD stop sends ForceEndpoint as ceiling - No UserStarted/StoppedSpeakingFrame emitted from STT When False (STT mode, u3-rt-pro only): AssemblyAI's model controls turn endings. @@ -172,10 +172,11 @@ class AssemblyAISTTService(WebsocketSTTService): # Warn if user sets a custom prompt (recommend testing without one first) if connection_params.prompt is not None: logger.warning( - "Custom prompt detected. We recommend testing with no prompt first, as this " - "will use our optimized default prompt for voice agents. Bad prompts may lead " - "to bad results. If you'd like to create your own prompt, check out our " - "prompting guide at: https://www.assemblyai.com/docs/streaming/prompting" + "Custom prompt detected. Prompting is a beta feature. We recommend testing " + "with no prompt first, as this will use our optimized default prompt for " + "voice agents. Bad prompts may lead to bad results. If you'd like to create " + "your own prompt, check out our prompting guide at: " + "https://www.assemblyai.com/docs/streaming/prompting" ) # When vad_force_turn_endpoint is enabled, configure connection params @@ -223,15 +224,15 @@ class AssemblyAISTTService(WebsocketSTTService): when the user is done speaking. VAD stop is the absolute ceiling. u3-rt-pro: - - min_end_of_turn_silence_when_confident defaults to 100ms (user can override) - - max_turn_silence is ALWAYS set equal to min_end_of_turn_silence_when_confident + - min_turn_silence defaults to 100ms (user can override) + - max_turn_silence is ALWAYS set equal to min_turn_silence to avoid double turn detection (AssemblyAI + Pipecat both analyzing) - If user sets max_turn_silence, it's ignored with a warning - end_of_turn_confidence_threshold: not set (API default) universal-streaming-*: - end_of_turn_confidence_threshold=0.0 (disable semantic turn detection) - - min_end_of_turn_silence_when_confident=160 + - min_turn_silence=160 - max_turn_silence: not set (API default) Args: @@ -244,8 +245,8 @@ class AssemblyAISTTService(WebsocketSTTService): updates = {} if is_u3_pro: - # u3-rt-pro: Synchronize max_turn_silence with min_end_of_turn_silence_when_confident - min_silence = connection_params.min_end_of_turn_silence_when_confident + # u3-rt-pro: Synchronize max_turn_silence with min_turn_silence + min_silence = connection_params.min_turn_silence if min_silence is None: min_silence = 100 @@ -254,20 +255,20 @@ class AssemblyAISTTService(WebsocketSTTService): logger.warning( f"Your max_turn_silence value ({connection_params.max_turn_silence}ms) will be " f"OVERRIDDEN in Pipecat mode (vad_force_turn_endpoint=True). It will be set to " - f"{min_silence}ms (matching min_end_of_turn_silence_when_confident) and SENT to " + f"{min_silence}ms (matching min_turn_silence) and SENT to " f"AssemblyAI to avoid double turn detection. To use your max_turn_silence as-is, " f"switch to STT mode (vad_force_turn_endpoint=False)." ) updates = { - "min_end_of_turn_silence_when_confident": min_silence, + "min_turn_silence": min_silence, "max_turn_silence": min_silence, } else: # universal-streaming: Different configuration (works differently) updates = { "end_of_turn_confidence_threshold": 0.0, - "min_end_of_turn_silence_when_confident": 160, + "min_turn_silence": 160, } # Apply updates if any @@ -292,7 +293,7 @@ class AssemblyAISTTService(WebsocketSTTService): - keyterms_prompt: List of terms to boost (can be empty array to clear) - prompt: Custom prompt text (u3-rt-pro only) - max_turn_silence: Maximum silence before forcing turn end - - min_end_of_turn_silence_when_confident: Silence before EOT check + - min_turn_silence: Silence before EOT check Args: delta: A :class:`STTSettings` (or ``AssemblyAISTTSettings``) delta. @@ -351,18 +352,18 @@ class AssemblyAISTTService(WebsocketSTTService): f"Updating max_turn_silence to: {conn_params.max_turn_silence}ms" ) - if hasattr(conn_params, "min_end_of_turn_silence_when_confident"): + if hasattr(conn_params, "min_turn_silence"): if ( old_conn_params is None - or conn_params.min_end_of_turn_silence_when_confident - != old_conn_params.min_end_of_turn_silence_when_confident + or conn_params.min_turn_silence + != old_conn_params.min_turn_silence ): - if conn_params.min_end_of_turn_silence_when_confident is not None: - update_config["min_end_of_turn_silence_when_confident"] = ( - conn_params.min_end_of_turn_silence_when_confident + if conn_params.min_turn_silence is not None: + update_config["min_turn_silence"] = ( + conn_params.min_turn_silence ) logger.info( - f"Updating min_end_of_turn_silence_when_confident to: {conn_params.min_end_of_turn_silence_when_confident}ms" + f"Updating min_turn_silence to: {conn_params.min_turn_silence}ms" ) # Send update if we have parameters to update diff --git a/test_assemblyai_custom.py b/test_assemblyai_custom.py new file mode 100755 index 000000000..c406918c0 --- /dev/null +++ b/test_assemblyai_custom.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +"""Custom AssemblyAI u3-rt-pro Test Script +Easy parameter tweaking for experimentation + +Edit the CONFIGURATION section below to test different settings! +""" + +import asyncio +import os +import sys + +from dotenv import load_dotenv +from loguru import logger + +from pipecat.audio.vad.silero import SileroVADAnalyzer +from pipecat.frames.frames import LLMRunFrame +from pipecat.pipeline.pipeline import Pipeline +from pipecat.pipeline.runner import PipelineRunner +from pipecat.pipeline.task import PipelineParams, PipelineTask +from pipecat.processors.aggregators.llm_context import LLMContext +from pipecat.processors.aggregators.llm_response_universal import ( + LLMContextAggregatorPair, + LLMUserAggregatorParams, +) +from pipecat.services.assemblyai.models import AssemblyAIConnectionParams +from pipecat.services.assemblyai.stt import AssemblyAISTTService +from pipecat.services.cartesia.tts import CartesiaTTSService +from pipecat.services.openai.llm import OpenAILLMService +from pipecat.transports.local.audio import LocalAudioTransport, LocalAudioTransportParams + +load_dotenv(override=True) + +# ============================================================================ +# CONFIGURATION +# ============================================================================ + +# Log Level: "DEBUG" for detailed logs, "INFO" for normal operation +LOG_LEVEL = "INFO" + +# ============================================================================ +# BOT IMPLEMENTATION +# ============================================================================ + + +async def main(): + """Run the custom test bot with your configured parameters.""" + # Setup logging + logger.remove(0) + logger.add(sys.stderr, level=LOG_LEVEL) + + logger.info("="*80) + logger.info("AssemblyAI u3-rt-pro Custom Test") + logger.info("="*80) + logger.info("Starting bot... Speak after you hear the greeting!") + logger.info("="*80) + + # Create local audio transport + transport = LocalAudioTransport( + LocalAudioTransportParams( + audio_in_enabled=True, + audio_out_enabled=True, + ) + ) + + # ======================================================================== + # EDIT PARAMETERS HERE + # ======================================================================== + + # Build connection params + connection_params = AssemblyAIConnectionParams( + # ==================================================================== + # Model Selection + # ==================================================================== + speech_model="u3-rt-pro", + # speech_model="universal-streaming-english", + # speech_model="universal-streaming-multilingual", + + # ==================================================================== + # Turn Detection Timing + # ==================================================================== + + # Minimum silence when confident about end of turn (milliseconds) + # Default: 100ms | Higher = more patient | Lower = faster responses + # Only used in Pipecat mode (vad_force_turn_endpoint=True) + min_turn_silence=100000, + # min_turn_silence=200, + # min_turn_silence=300, + + # Maximum turn silence (milliseconds) + # WARNING: In Pipecat mode (vad_force_turn_endpoint=True), this is + # automatically set equal to min_turn_silence + # to avoid double turn detection. Only used as-is in STT mode. + max_turn_silence=500, + + # End of turn confidence threshold (0.0 to 1.0) + # Higher = requires more confidence before ending turn + # end_of_turn_confidence_threshold=0.8, + + # ==================================================================== + # Prompting & Boosting + # ==================================================================== + + # Custom Prompt (WARNING: test carefully, default is optimized!) + # None = Use AssemblyAI's optimized default (recommended for 88% accuracy) + prompt=None, + # prompt="Transcribe speech with focus on technical terms.", + # prompt="Context: Medical conversation. Transcribe accurately.", + + # Keyterms Prompting (boosts recognition for specific words) + # NOTE: Cannot use both prompt and keyterms_prompt! + keyterms_prompt=None, + # keyterms_prompt=["Pipecat", "AssemblyAI", "OpenAI", "Cartesia"], + # keyterms_prompt=["Python", "JavaScript", "TypeScript", "API"], + + # ==================================================================== + # Diarization (Speaker Identification) + # ==================================================================== + + # Enable speaker labels (identifies different speakers) + speaker_labels=None, # None or True + # speaker_labels=True, + + # ==================================================================== + # Audio Configuration + # ==================================================================== + + # Audio sample rate (Hz) + # sample_rate=16000, + # sample_rate=8000, + + # Audio encoding format + # encoding="pcm_s16le", # Default: 16-bit PCM + # encoding="pcm_mulaw", # μ-law encoding (telephony) + + # ==================================================================== + # Other Options + # ==================================================================== + + # Format transcript turns (applies formatting rules) + # format_turns=True, # Default + # format_turns=False, + + # Language detection (only for universal-streaming-multilingual) + # language_detection=True, + ) + + # Log connection parameters for debugging + logger.info("="*80) + logger.info("CONNECTION PARAMETERS:") + logger.info(f" speech_model: {connection_params.speech_model}") + logger.info(f" min_turn_silence: {connection_params.min_turn_silence}") + logger.info(f" max_turn_silence: {connection_params.max_turn_silence}") + logger.info(f" sample_rate: {connection_params.sample_rate}") + logger.info(f" encoding: {connection_params.encoding}") + logger.info(f" prompt: {connection_params.prompt}") + logger.info(f" keyterms_prompt: {connection_params.keyterms_prompt}") + logger.info(f" speaker_labels: {connection_params.speaker_labels}") + logger.info(f" format_turns: {connection_params.format_turns}") + logger.info(f" end_of_turn_confidence_threshold: {connection_params.end_of_turn_confidence_threshold}") + logger.info(f" language_detection: {connection_params.language_detection}") + logger.info("="*80) + + # AssemblyAI Speech-to-Text Service + stt = AssemblyAISTTService( + api_key=os.getenv("ASSEMBLYAI_API_KEY"), + connection_params=connection_params, + + # Turn Detection Mode + # True = Pipecat mode (VAD + Smart Turn controls turns) + # False = STT mode (u3-rt-pro model controls turns) + vad_force_turn_endpoint=True, + + # Speaker Formatting (only used if speaker_labels=True) + # None = Just log speaker IDs, don't modify transcript + speaker_format=None, + # speaker_format="{text}", + # speaker_format="{speaker}: {text}", + # speaker_format="[{speaker}] {text}", + + # Additional available parameters (uncomment to use): + # should_interrupt=True, # Only for STT mode + ) + + # ======================================================================== + + # Text-to-Speech + tts = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + voice_id="a0e99841-438c-4a64-b679-ae501e7d6091", # Conversational English + ) + + # LLM + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + model="gpt-4", + ) + + # Conversation context + messages = [ + { + "role": "system", + "content": ( + "You are a helpful voice assistant testing the AssemblyAI u3-rt-pro model. " + "Keep responses very brief (1-2 sentences). " + "Start by introducing yourself briefly and asking the user to speak." + ), + }, + ] + + context = LLMContext(messages) + + # Configure aggregator based on mode + # In STT mode, don't use VAD (model handles turn detection) + # In Pipecat mode, use VAD + Smart Turn + vad_force_turn_endpoint = True # Must match the value in stt configuration above + user_params = None + if vad_force_turn_endpoint: + user_params = LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()) + + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=user_params, + ) + + # Pipeline + pipeline = Pipeline( + [ + transport.input(), + stt, + user_aggregator, + llm, + tts, + transport.output(), + assistant_aggregator, + ] + ) + + # Task + task = PipelineTask( + pipeline, + params=PipelineParams( + enable_metrics=True, + enable_usage_metrics=True, + ), + ) + + # Start the conversation + await task.queue_frames([LLMRunFrame()]) + + # Run + runner = PipelineRunner() + await runner.run(task) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/test_assemblyai_interactive.py b/test_assemblyai_interactive.py new file mode 100755 index 000000000..ce468ab3d --- /dev/null +++ b/test_assemblyai_interactive.py @@ -0,0 +1,750 @@ +#!/usr/bin/env python3 +"""Interactive AssemblyAI u3-rt-pro Comprehensive Test Suite + +Tests all features with detailed scenarios: +- Basic configuration variations +- Prompting and keyterms with difficult names +- Diarization +- Dynamic parameter updates (single and multiple) +- Mode comparisons +- STT mode timing experiments (testing silence parameters) +- Edge cases + +Usage: + python test_assemblyai_interactive.py +""" + +import asyncio +import os +import sys +from typing import Optional + +from dotenv import load_dotenv +from loguru import logger + +from pipecat.audio.vad.silero import SileroVADAnalyzer +from pipecat.frames.frames import LLMRunFrame, STTUpdateSettingsFrame +from pipecat.pipeline.pipeline import Pipeline +from pipecat.pipeline.runner import PipelineRunner +from pipecat.pipeline.task import PipelineParams, PipelineTask +from pipecat.processors.aggregators.llm_context import LLMContext +from pipecat.processors.aggregators.llm_response_universal import ( + LLMContextAggregatorPair, + LLMUserAggregatorParams, +) +from pipecat.services.assemblyai.models import AssemblyAIConnectionParams +from pipecat.services.assemblyai.stt import AssemblyAISTTService, AssemblyAISTTSettings +from pipecat.services.cartesia.tts import CartesiaTTSService +from pipecat.services.openai.llm import OpenAILLMService +from pipecat.transports.local.audio import LocalAudioTransport, LocalAudioTransportParams + +load_dotenv(override=True) + +logger.remove(0) +logger.add(sys.stderr, level="INFO") + + +async def run_bot( + connection_params: AssemblyAIConnectionParams, + test_name: str, + vad_force_turn_endpoint: bool = True, + speaker_format: Optional[str] = None, + test_dynamic_updates: Optional[callable] = None, +): + """Run the voice bot with specified configuration.""" + logger.info("="*80) + logger.info(f"TEST: {test_name}") + logger.info("="*80) + logger.info("Starting bot... Speak into your microphone after you hear the greeting!") + logger.info("="*80) + + # Create local audio transport + transport = LocalAudioTransport( + LocalAudioTransportParams( + audio_in_enabled=True, + audio_out_enabled=True, + ) + ) + + # AssemblyAI Speech-to-Text + stt = AssemblyAISTTService( + api_key=os.getenv("ASSEMBLYAI_API_KEY"), + connection_params=connection_params, + vad_force_turn_endpoint=vad_force_turn_endpoint, + speaker_format=speaker_format, + ) + + # Text-to-Speech + tts = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + voice_id="a0e99841-438c-4a64-b679-ae501e7d6091", + ) + + # LLM + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + model="gpt-4", + ) + + # Conversation context + messages = [ + { + "role": "system", + "content": ( + "You are a helpful voice assistant testing the AssemblyAI u3-rt-pro model. " + "Keep responses very brief (1-2 sentences). " + "Start by introducing yourself briefly and asking the user to speak." + ), + }, + ] + + context = LLMContext(messages) + + # Configure aggregator based on mode + user_params = None + if vad_force_turn_endpoint: + user_params = LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()) + + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=user_params, + ) + + # Pipeline + pipeline = Pipeline( + [ + transport.input(), + stt, + user_aggregator, + llm, + tts, + transport.output(), + assistant_aggregator, + ] + ) + + # Task + task = PipelineTask( + pipeline, + params=PipelineParams( + enable_metrics=True, + enable_usage_metrics=True, + ), + ) + + # Handle dynamic updates if provided + if test_dynamic_updates: + asyncio.create_task(test_dynamic_updates(task)) + + # Start the conversation + await task.queue_frames([LLMRunFrame()]) + + # Run + runner = PipelineRunner() + await runner.run(task) + + +# ============================================================================ +# Test Configurations +# ============================================================================ + +# === BASIC CONFIGURATION (1-3) === + +async def test_01_basic_100ms(): + """Test 1: Basic default configuration (100ms).""" + connection_params = AssemblyAIConnectionParams( + speech_model="u3-rt-pro", + min_turn_silence=100, + ) + await run_bot(connection_params, "Basic Default Configuration (100ms)") + + +async def test_02_custom_200ms(): + """Test 2: Custom min_end_of_turn_silence (200ms).""" + connection_params = AssemblyAIConnectionParams( + speech_model="u3-rt-pro", + min_turn_silence=200, + ) + await run_bot(connection_params, "Custom Turn Silence (200ms)") + + +async def test_03_custom_500ms(): + """Test 3: Longer silence threshold (500ms).""" + connection_params = AssemblyAIConnectionParams( + speech_model="u3-rt-pro", + min_turn_silence=500, + ) + await run_bot(connection_params, "Longer Turn Silence (500ms)") + + +# === PROMPTING & WARNINGS (4-7) === + +async def test_04_max_warning(): + """Test 4: max_turn_silence warning (should be overridden).""" + logger.warning("⚠️ EXPECT WARNING: max_turn_silence will be overridden") + connection_params = AssemblyAIConnectionParams( + speech_model="u3-rt-pro", + max_turn_silence=500, + ) + await run_bot(connection_params, "max_turn_silence Override Warning") + + +async def test_05_prompt_warning(): + """Test 5: Custom prompt warning.""" + logger.warning("⚠️ EXPECT WARNING: Custom prompts should be tested carefully") + connection_params = AssemblyAIConnectionParams( + speech_model="u3-rt-pro", + prompt="Transcribe speech accurately with proper punctuation.", + ) + await run_bot(connection_params, "Custom Prompt Warning Test") + + +async def test_06_prompt_keyterms_conflict(): + """Test 6: Prompt + keyterms conflict (should error).""" + logger.error("❌ EXPECT ERROR: Cannot use both prompt and keyterms_prompt") + try: + connection_params = AssemblyAIConnectionParams( + speech_model="u3-rt-pro", + prompt="Custom prompt", + keyterms_prompt=["test"], + ) + await run_bot(connection_params, "Prompt + Keyterms Conflict (ERROR)") + except ValueError as e: + logger.error(f"✅ EXPECTED ERROR: {e}") + input("\nPress Enter to continue...") + return + + +async def test_07_keyterms_difficult(): + """Test 7: Keyterms with difficult/unusual names.""" + # Use names that STT wouldn't normally get right + keyterms = ["Xiomara", "Saoirse", "Krzystof", "Nguyen", "Pipecat", "AssemblyAI"] + connection_params = AssemblyAIConnectionParams( + speech_model="u3-rt-pro", + keyterms_prompt=keyterms, + ) + logger.info("🎯 Boosted terms: Xiomara, Saoirse, Krzystof, Nguyen, Pipecat, AssemblyAI") + logger.info(" Try saying these difficult names to test boosting!") + await run_bot(connection_params, "Keyterms with Difficult Names") + + +# === DIARIZATION (8-9) === + +async def test_08_diarization_basic(): + """Test 8: Basic diarization (speaker IDs logged).""" + connection_params = AssemblyAIConnectionParams( + speech_model="u3-rt-pro", + speaker_labels=True, + ) + logger.info("🎤 Diarization enabled - speaker IDs will be logged") + logger.info(" Try having multiple people speak!") + await run_bot(connection_params, "Diarization - Basic") + + +async def test_09_diarization_xml(): + """Test 9: Diarization with XML formatting.""" + connection_params = AssemblyAIConnectionParams( + speech_model="u3-rt-pro", + speaker_labels=True, + ) + logger.info("🎤 Diarization with XML tags") + logger.info(" Transcripts will include text") + await run_bot( + connection_params, + "Diarization - XML Formatting", + speaker_format="{text}", + ) + + +# === DYNAMIC UPDATES - SINGLE PARAMETER (10-13) === + +async def test_10_dynamic_keyterms(): + """Test 10: Dynamic keyterms update with difficult names.""" + connection_params = AssemblyAIConnectionParams( + speech_model="u3-rt-pro", + ) + + async def dynamic_update(task): + logger.info("\n" + "="*80) + logger.info("PHASE 1: No keyterms boosting") + logger.info(" Try saying: Xiomara, Saoirse, Krzystof") + logger.info(" (May not transcribe correctly)") + logger.info("="*80) + await asyncio.sleep(15) + + logger.info("\n" + "="*80) + logger.info("🔄 UPDATING: Adding keyterms boost") + logger.info("="*80) + await task.queue_frame( + STTUpdateSettingsFrame( + delta=AssemblyAISTTSettings( + connection_params=AssemblyAIConnectionParams( + keyterms_prompt=["Xiomara", "Saoirse", "Krzystof", "Nguyen"] + ) + ) + ) + ) + logger.info("\n" + "="*80) + logger.info("PHASE 2: Keyterms NOW boosted") + logger.info(" Say the same names again: Xiomara, Saoirse, Krzystof") + logger.info(" (Should transcribe better now!)") + logger.info("="*80) + + logger.info("🔄 This test has 2 phases:") + logger.info(" Phase 1 (15s): No boosting - names may be wrong") + logger.info(" Phase 2: Keyterms added - names should improve") + await run_bot( + connection_params, + "Dynamic Keyterms Update (Before/After)", + test_dynamic_updates=dynamic_update, + ) + + +async def test_11_dynamic_silence(): + """Test 11: Dynamic silence parameter update (dramatic change).""" + connection_params = AssemblyAIConnectionParams( + speech_model="u3-rt-pro", + min_turn_silence=100, + ) + + async def dynamic_update(task): + logger.info("\n" + "="*80) + logger.info("PHASE 1: Quick responses (100ms silence threshold)") + logger.info(" Speak normally - bot responds quickly") + logger.info("="*80) + await asyncio.sleep(10) + + logger.info("\n" + "="*80) + logger.info("🔄 UPDATING: Changing silence from 100ms → 3000ms (3 seconds!)") + logger.info("="*80) + await task.queue_frame( + STTUpdateSettingsFrame( + delta=AssemblyAISTTSettings( + connection_params=AssemblyAIConnectionParams( + min_turn_silence=3000 + ) + ) + ) + ) + logger.info("\n" + "="*80) + logger.info("PHASE 2: Patient responses (3 second silence threshold)") + logger.info(" Bot will wait 3 full seconds before responding") + logger.info(" Try pausing mid-sentence - bot should NOT interrupt") + logger.info("="*80) + + logger.info("🔄 Dramatic change: 100ms → 3000ms after 10 seconds") + await run_bot( + connection_params, + "Dynamic Silence Update (100ms → 3s)", + test_dynamic_updates=dynamic_update, + ) + + +async def test_12_dynamic_prompt(): + """Test 12: Dynamic prompt update with keyterms in prompt.""" + connection_params = AssemblyAIConnectionParams( + speech_model="u3-rt-pro", + ) + + async def dynamic_update(task): + logger.info("\n" + "="*80) + logger.info("PHASE 1: Default prompt (no keyterms)") + logger.info(" Try saying: Xiomara, Saoirse, Krzystof") + logger.info(" (May not transcribe correctly)") + logger.info("="*80) + await asyncio.sleep(15) + + logger.info("\n" + "="*80) + logger.info("🔄 UPDATING: Adding custom prompt with keyterms") + logger.info("="*80) + custom_prompt = """Transcribe verbatim. Rules: +1) Always include punctuation in output. +2) Use period/question mark ONLY for complete sentences. +3) Use comma for mid-sentence pauses. +4) Use no punctuation for incomplete trailing speech. +5) Filler words (um, uh, so, like) indicate speaker will continue. + +Pay special attention to these names and transcribe them exactly: Xiomara, Saoirse, Krzystof, Nguyen.""" + await task.queue_frame( + STTUpdateSettingsFrame( + delta=AssemblyAISTTSettings( + connection_params=AssemblyAIConnectionParams( + prompt=custom_prompt + ) + ) + ) + ) + logger.info("\n" + "="*80) + logger.info("PHASE 2: Prompt with keyterms NOW active") + logger.info(" Say the same names again: Xiomara, Saoirse, Krzystof") + logger.info(" (Should transcribe better now!)") + logger.info("="*80) + + logger.info("🔄 This test has 2 phases:") + logger.info(" Phase 1 (15s): Default prompt - names may be wrong") + logger.info(" Phase 2: Custom prompt with keyterms - names should improve") + await run_bot( + connection_params, + "Dynamic Prompt Update (with keyterms)", + test_dynamic_updates=dynamic_update, + ) + + +async def test_13_dynamic_clear_keyterms(): + """Test 13: Clear keyterms dynamically.""" + connection_params = AssemblyAIConnectionParams( + speech_model="u3-rt-pro", + keyterms_prompt=["Pipecat", "AssemblyAI"], + ) + + async def dynamic_update(task): + await asyncio.sleep(10) + logger.info("🔄 UPDATING: Clearing keyterms (empty array)") + await task.queue_frame( + STTUpdateSettingsFrame( + delta=AssemblyAISTTSettings( + connection_params=AssemblyAIConnectionParams( + keyterms_prompt=[] + ) + ) + ) + ) + + logger.info("🎯 Initial: Pipecat, AssemblyAI boosted") + logger.info("🔄 After 10s: Keyterms will be cleared") + await run_bot( + connection_params, + "Dynamic Clear Keyterms", + test_dynamic_updates=dynamic_update, + ) + + +# === DYNAMIC UPDATES - MULTIPLE PARAMETERS (14-15) === + +async def test_14_multi_param_update(): + """Test 14: Update multiple parameters at once.""" + connection_params = AssemblyAIConnectionParams( + speech_model="u3-rt-pro", + min_turn_silence=100, + ) + + async def dynamic_update(task): + await asyncio.sleep(10) + logger.info("🔄 UPDATING MULTIPLE: keyterms + silence") + await task.queue_frame( + STTUpdateSettingsFrame( + delta=AssemblyAISTTSettings( + connection_params=AssemblyAIConnectionParams( + keyterms_prompt=["Xiomara", "Pipecat"], + min_turn_silence=250, + ) + ) + ) + ) + + logger.info("🔄 After 10s: Will update BOTH keyterms AND silence threshold") + await run_bot( + connection_params, + "Multiple Parameter Update", + test_dynamic_updates=dynamic_update, + ) + + +async def test_15_complex_sequence(): + """Test 15: Complex multi-stage update sequence.""" + connection_params = AssemblyAIConnectionParams( + speech_model="u3-rt-pro", + ) + + async def dynamic_update(task): + logger.info("Stage 1: Initial (10s)") + await asyncio.sleep(10) + + logger.info("🔄 Stage 2: Add keyterms") + await task.queue_frame( + STTUpdateSettingsFrame( + delta=AssemblyAISTTSettings( + connection_params=AssemblyAIConnectionParams( + keyterms_prompt=["Pipecat"] + ) + ) + ) + ) + await asyncio.sleep(10) + + logger.info("🔄 Stage 3: Change silence") + await task.queue_frame( + STTUpdateSettingsFrame( + delta=AssemblyAISTTSettings( + connection_params=AssemblyAIConnectionParams( + min_turn_silence=200 + ) + ) + ) + ) + await asyncio.sleep(10) + + logger.info("🔄 Stage 4: Update both") + await task.queue_frame( + STTUpdateSettingsFrame( + delta=AssemblyAISTTSettings( + connection_params=AssemblyAIConnectionParams( + keyterms_prompt=["AssemblyAI", "OpenAI"], + min_turn_silence=150, + ) + ) + ) + ) + + logger.info("🔄 Multi-stage: 4 configuration changes over 30 seconds") + await run_bot( + connection_params, + "Complex Update Sequence (4 stages)", + test_dynamic_updates=dynamic_update, + ) + + +# === MODE COMPARISON (16-17) === + +async def test_16_pipecat_mode(): + """Test 16: Pipecat mode (VAD + Smart Turn controls turns).""" + connection_params = AssemblyAIConnectionParams( + speech_model="u3-rt-pro", + min_turn_silence=100, + ) + logger.info("🎯 Pipecat Mode: VAD + Smart Turn control turn detection") + logger.info(" Your min_end_of_turn_silence is sent but ForceEndpoint overrides it") + await run_bot( + connection_params, + "Pipecat Mode (VAD + Smart Turn)", + vad_force_turn_endpoint=True, + ) + + +async def test_17_stt_mode(): + """Test 17: STT mode (model controls turns).""" + connection_params = AssemblyAIConnectionParams( + speech_model="u3-rt-pro", + min_turn_silence=100, + ) + logger.info("🎯 STT Mode: u3-rt-pro model controls turn detection") + logger.info(" No ForceEndpoint - parameters are respected") + await run_bot( + connection_params, + "STT Mode (Model Turn Detection)", + vad_force_turn_endpoint=False, + ) + + +# === STT MODE TIMING EXPERIMENTS (18-20) === + +async def test_18_stt_long_max_short_min(): + """Test 18: STT mode - Long max_turn_silence + Short min (5000ms + 100ms).""" + connection_params = AssemblyAIConnectionParams( + speech_model="u3-rt-pro", + min_turn_silence=100, # Short - quick confident turns + max_turn_silence=5000, # Long - allows pauses up to 5 seconds + ) + logger.info("🎯 STT Mode: Testing max/min parameter interaction") + logger.info(" min_turn_silence: 100ms (quick when confident)") + logger.info(" max_turn_silence: 5000ms (allows up to 5 second pauses)") + logger.info(" Try: Quick sentences (should respond fast) + Long pauses mid-thought") + await run_bot( + connection_params, + "STT: Long Max (5s) + Short Min (100ms)", + vad_force_turn_endpoint=False, + ) + + +async def test_19_stt_long_min(): + """Test 19: STT mode - Long min_turn_silence (3000ms).""" + connection_params = AssemblyAIConnectionParams( + speech_model="u3-rt-pro", + min_turn_silence=3000, # 3 seconds + max_turn_silence=5000, # 5 seconds + ) + logger.info("🎯 STT Mode: Testing long minimum silence requirement") + logger.info(" min_turn_silence: 3000ms") + logger.info(" max_turn_silence: 5000ms") + logger.info(" Bot will wait 3 full seconds of silence before responding!") + logger.info(" Try: Speaking with short pauses - bot should NOT interrupt") + await run_bot( + connection_params, + "STT: Long Min (3s)", + vad_force_turn_endpoint=False, + ) + + +async def test_20_stt_both_short(): + """Test 20: STT mode - Both short (max=300ms, min=100ms).""" + connection_params = AssemblyAIConnectionParams( + speech_model="u3-rt-pro", + min_turn_silence=100, # 100ms + max_turn_silence=300, # 300ms + ) + logger.info("🎯 STT Mode: Testing aggressive/quick response timing") + logger.info(" min_turn_silence: 100ms") + logger.info(" max_turn_silence: 300ms") + logger.info(" Bot will respond VERY quickly to any pause!") + logger.info(" Try: Speaking with natural pauses - expect quick responses") + await run_bot( + connection_params, + "STT: Both Short (300ms/100ms)", + vad_force_turn_endpoint=False, + ) + + +# === EDGE CASES (21-23) === + +async def test_21_very_long_silence(): + """Test 21: Very long silence threshold (STT mode only).""" + connection_params = AssemblyAIConnectionParams( + speech_model="u3-rt-pro", + min_turn_silence=10000, # 10 seconds + ) + logger.warning("⚠️ STT Mode with 10 second silence threshold") + logger.info(" Bot will wait 10 seconds of silence before responding!") + await run_bot( + connection_params, + "Very Long Silence (10s) - STT Mode", + vad_force_turn_endpoint=False, + ) + + +async def test_22_very_short_silence(): + """Test 22: Very short silence threshold (50ms).""" + connection_params = AssemblyAIConnectionParams( + speech_model="u3-rt-pro", + min_turn_silence=50, + ) + logger.info("⚡ Very short silence threshold (50ms)") + logger.info(" Bot will respond very quickly!") + await run_bot(connection_params, "Very Short Silence (50ms)") + + +async def test_23_keyterms_plus_diarization(): + """Test 23: Keyterms + Diarization combined.""" + connection_params = AssemblyAIConnectionParams( + speech_model="u3-rt-pro", + keyterms_prompt=["Xiomara", "Saoirse", "Pipecat"], + speaker_labels=True, + ) + logger.info("🎯 Keyterms + 🎤 Diarization both enabled") + logger.info(" Try multiple speakers saying difficult names!") + await run_bot( + connection_params, + "Keyterms + Diarization Combined", + speaker_format="[{speaker}] {text}", + ) + + +# ============================================================================ +# Interactive Menu +# ============================================================================ + + +def show_menu(): + """Display the comprehensive test menu.""" + print("\n" + "="*80) + print("AssemblyAI u3-rt-pro Comprehensive Test Suite") + print("="*80) + print("\n📋 BASIC CONFIGURATION (1-3)") + print(" 1. Basic Default (100ms)") + print(" 2. Custom Silence (200ms)") + print(" 3. Longer Silence (500ms)") + + print("\n⚠️ PROMPTING & WARNINGS (4-7)") + print(" 4. max_turn_silence Warning") + print(" 5. Custom Prompt Warning") + print(" 6. Prompt + Keyterms Conflict (ERROR)") + print(" 7. Keyterms with Difficult Names") + + print("\n🎤 DIARIZATION (8-9)") + print(" 8. Diarization - Basic") + print(" 9. Diarization - XML Formatting") + + print("\n🔄 DYNAMIC UPDATES - SINGLE (10-13)") + print(" 10. Dynamic Keyterms (Before/After with difficult names)") + print(" 11. Dynamic Silence (100ms → 3s DRAMATIC)") + print(" 12. Dynamic Prompt with Keyterms (Before/After)") + print(" 13. Dynamic Clear Keyterms") + + print("\n🔄 DYNAMIC UPDATES - MULTIPLE (14-15)") + print(" 14. Multiple Parameters at Once") + print(" 15. Complex Update Sequence (4 stages)") + + print("\n⚖️ MODE COMPARISON (16-17)") + print(" 16. Pipecat Mode (VAD + Smart Turn)") + print(" 17. STT Mode (Model Turn Detection)") + + print("\n⏱️ STT MODE TIMING EXPERIMENTS (18-20)") + print(" 18. STT: Long Max (5s) + Short Min (100ms)") + print(" 19. STT: Long Min (3s)") + print(" 20. STT: Both Short (300ms/100ms)") + + print("\n🎯 EDGE CASES (21-23)") + print(" 21. Very Long Silence (10s - STT Mode)") + print(" 22. Very Short Silence (50ms)") + print(" 23. Keyterms + Diarization Combined") + + print("\n 0. Exit") + print("\n" + "="*80) + + +async def main(): + """Main interactive menu.""" + tests = { + "1": test_01_basic_100ms, + "2": test_02_custom_200ms, + "3": test_03_custom_500ms, + "4": test_04_max_warning, + "5": test_05_prompt_warning, + "6": test_06_prompt_keyterms_conflict, + "7": test_07_keyterms_difficult, + "8": test_08_diarization_basic, + "9": test_09_diarization_xml, + "10": test_10_dynamic_keyterms, + "11": test_11_dynamic_silence, + "12": test_12_dynamic_prompt, + "13": test_13_dynamic_clear_keyterms, + "14": test_14_multi_param_update, + "15": test_15_complex_sequence, + "16": test_16_pipecat_mode, + "17": test_17_stt_mode, + "18": test_18_stt_long_max_short_min, + "19": test_19_stt_long_min, + "20": test_20_stt_both_short, + "21": test_21_very_long_silence, + "22": test_22_very_short_silence, + "23": test_23_keyterms_plus_diarization, + } + + while True: + show_menu() + choice = input("Enter test number (or 0 to exit): ").strip() + + if choice == "0": + print("\n👋 Goodbye!") + break + + if choice in tests: + try: + await tests[choice]() + except KeyboardInterrupt: + print("\n\n⚠️ Test interrupted by user") + except Exception as e: + logger.error(f"Test failed with error: {e}") + import traceback + traceback.print_exc() + + input("\n\nPress Enter to return to menu...") + else: + print(f"\n❌ Invalid choice: {choice}") + input("Press Enter to continue...") + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + print("\n\n👋 Goodbye!") diff --git a/test_assemblyai_u3pro.py b/test_assemblyai_u3pro.py new file mode 100644 index 000000000..ace700753 --- /dev/null +++ b/test_assemblyai_u3pro.py @@ -0,0 +1,589 @@ +#!/usr/bin/env python3 +"""AssemblyAI u3-rt-pro Comprehensive Test Script + +Tests all features: +- Basic configuration +- Prompting and keyterms +- Diarization +- Dynamic updates +- Turn detection modes + +Usage: + python test_assemblyai_u3pro.py --test + python test_assemblyai_u3pro.py --interactive +""" + +import argparse +import asyncio +import os +import sys +from typing import List + +from dotenv import load_dotenv +from loguru import logger + +# Add src to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src")) + +from pipecat.audio.vad.silero import SileroVADAnalyzer +from pipecat.frames.frames import ( + EndFrame, + Frame, + LLMRunFrame, + STTUpdateSettingsFrame, + TranscriptionFrame, +) +from pipecat.pipeline.pipeline import Pipeline +from pipecat.pipeline.runner import PipelineRunner +from pipecat.pipeline.task import PipelineTask +from pipecat.processors.aggregators.llm_context import LLMContext +from pipecat.processors.aggregators.llm_response_universal import ( + LLMContextAggregatorPair, + LLMUserAggregatorParams, +) +from pipecat.processors.frame_processor import FrameDirection, FrameProcessor +from pipecat.services.assemblyai.models import AssemblyAIConnectionParams +from pipecat.services.assemblyai.stt import AssemblyAISTTService +from pipecat.services.cartesia.tts import CartesiaTTSService +from pipecat.services.openai.llm import OpenAILLMService +from pipecat.transports.local.audio import LocalAudioTransport, LocalAudioTransportParams + +load_dotenv() + +# Test configuration +class TestConfig: + """Centralized test configuration.""" + + ASSEMBLYAI_API_KEY = os.getenv("ASSEMBLYAI_API_KEY") + OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") + CARTESIA_API_KEY = os.getenv("CARTESIA_API_KEY") + + @classmethod + def validate(cls): + """Validate all required API keys are set.""" + missing = [] + if not cls.ASSEMBLYAI_API_KEY: + missing.append("ASSEMBLYAI_API_KEY") + if not cls.OPENAI_API_KEY: + missing.append("OPENAI_API_KEY") + if not cls.CARTESIA_API_KEY: + missing.append("CARTESIA_API_KEY") + + if missing: + logger.error(f"Missing required environment variables: {', '.join(missing)}") + return False + return True + + +class TranscriptionLogger(FrameProcessor): + """Log transcriptions for test verification.""" + + async def process_frame(self, frame: Frame, direction: FrameDirection): + if isinstance(frame, TranscriptionFrame): + logger.info(f"📝 TRANSCRIPTION: {frame.text}") + logger.info(f" Speaker: {frame.user_id}") + logger.info(f" Finalized: {frame.finalized}") + if hasattr(frame, "result") and frame.result: + if hasattr(frame.result, "speaker"): + logger.info(f" Diarization: {frame.result.speaker}") + + await self.push_frame(frame, direction) + + +async def create_basic_voice_agent( + connection_params: AssemblyAIConnectionParams, + vad_force_turn_endpoint: bool = True, + speaker_format: str = None, +) -> tuple[PipelineTask, LocalAudioTransport]: + """Create a basic voice agent for testing. + + Args: + connection_params: AssemblyAI connection parameters + vad_force_turn_endpoint: Turn detection mode + speaker_format: Optional speaker formatting string + + Returns: + Tuple of (PipelineTask, LocalAudioTransport) + """ + # Create local audio transport (uses your microphone and speakers) + transport = LocalAudioTransport( + params=LocalAudioTransportParams( + audio_in_enabled=True, + audio_out_enabled=True, + ) + ) + + # Create STT + stt = AssemblyAISTTService( + api_key=TestConfig.ASSEMBLYAI_API_KEY, + connection_params=connection_params, + vad_force_turn_endpoint=vad_force_turn_endpoint, + speaker_format=speaker_format, + ) + + # Create TTS + tts = CartesiaTTSService( + api_key=TestConfig.CARTESIA_API_KEY, + voice_id="a0e99841-438c-4a64-b679-ae501e7d6091", # Conversational English + ) + + # Create LLM context and service + messages = [ + { + "role": "system", + "content": ( + "You are a helpful voice assistant. Keep responses brief and natural. " + "If you see speaker tags like text, acknowledge " + "that you understand multiple speakers are present." + ), + } + ] + + context = LLMContext(messages) + llm = OpenAILLMService(api_key=TestConfig.OPENAI_API_KEY, model="gpt-4") + + # Create aggregators with VAD + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, + user_params=LLMUserAggregatorParams( + vad_analyzer=SileroVADAnalyzer(), + ), + ) + + # Create transcription logger + transcription_logger = TranscriptionLogger() + + # Create pipeline + pipeline = Pipeline( + [ + transport.input(), + stt, + transcription_logger, + user_aggregator, + llm, + tts, + transport.output(), + assistant_aggregator, + ] + ) + + # Create task + task = PipelineTask(pipeline) + + return task, transport + + +# ============================================================================ +# Test Functions +# ============================================================================ + + +async def test_basic_config(): + """Test 1: Basic default configuration.""" + logger.info("=" * 80) + logger.info("TEST 1: Basic Default Configuration") + logger.info("=" * 80) + + connection_params = AssemblyAIConnectionParams(speech_model="u3-rt-pro") + + task, transport = await create_basic_voice_agent(connection_params) + + logger.info("✅ Service created successfully with default params") + logger.info("Expected: min=max=100ms, u3-rt-pro model") + logger.info("Speak into your microphone to test transcription") + + # Trigger initial bot greeting + await task.queue_frames([LLMRunFrame()]) + + runner = PipelineRunner() + await runner.run(task) + + +async def test_custom_min_silence(): + """Test 2: Custom min_turn_silence.""" + logger.info("=" * 80) + logger.info("TEST 2: Custom min_turn_silence") + logger.info("=" * 80) + + connection_params = AssemblyAIConnectionParams( + speech_model="u3-rt-pro", min_turn_silence=200 + ) + + task, transport = await create_basic_voice_agent(connection_params) + + logger.info("✅ Service created with min=200ms") + logger.info("Expected: Both min and max set to 200ms") + logger.info("Speak short phrases and observe turn detection timing") + + runner = PipelineRunner() + await runner.run(task) + + +async def test_max_silence_warning(): + """Test 3: Setting max_turn_silence should trigger warning.""" + logger.info("=" * 80) + logger.info("TEST 3: max_turn_silence Warning") + logger.info("=" * 80) + + connection_params = AssemblyAIConnectionParams( + speech_model="u3-rt-pro", + min_turn_silence=100, + max_turn_silence=500, # Should trigger warning + ) + + task, transport = await create_basic_voice_agent(connection_params) + + logger.info("⚠️ Check logs above for warning about max_turn_silence being overridden") + logger.info("Expected: Warning logged, max set to 100ms (same as min)") + + runner = PipelineRunner() + await runner.run(task) + + +async def test_custom_prompt_warning(): + """Test 5: Custom prompt should trigger warning.""" + logger.info("=" * 80) + logger.info("TEST 5: Custom Prompt Warning") + logger.info("=" * 80) + + connection_params = AssemblyAIConnectionParams( + speech_model="u3-rt-pro", + prompt="Transcribe verbatim. Always include punctuation.", + ) + + task, transport = await create_basic_voice_agent(connection_params) + + logger.info("⚠️ Check logs above for warning about testing without prompt first") + logger.info("Expected: Warning logged, service continues with custom prompt") + + runner = PipelineRunner() + await runner.run(task) + + +async def test_prompt_keyterms_conflict(): + """Test 6: Prompt + keyterms_prompt should raise error.""" + logger.info("=" * 80) + logger.info("TEST 6: Prompt + Keyterms Conflict (Error)") + logger.info("=" * 80) + + try: + connection_params = AssemblyAIConnectionParams( + speech_model="u3-rt-pro", + prompt="Custom prompt", + keyterms_prompt=["test", "words"], + ) + + task, transport = await create_basic_voice_agent(connection_params) + logger.error("❌ TEST FAILED: Should have raised ValueError") + except ValueError as e: + logger.info(f"✅ TEST PASSED: ValueError raised as expected") + logger.info(f" Error message: {e}") + + +async def test_keyterms_basic(): + """Test 7: Basic keyterms at initialization.""" + logger.info("=" * 80) + logger.info("TEST 7: Basic Keyterms Prompting") + logger.info("=" * 80) + + connection_params = AssemblyAIConnectionParams( + speech_model="u3-rt-pro", + keyterms_prompt=["Pipecat", "AssemblyAI", "Universal-3", "streaming"], + ) + + task, transport = await create_basic_voice_agent(connection_params) + + logger.info("✅ Service created with keyterms: Pipecat, AssemblyAI, Universal-3, streaming") + logger.info("Expected: Boosted recognition for these terms") + logger.info("Try saying: 'I'm testing Pipecat with AssemblyAI Universal-3 for streaming'") + + runner = PipelineRunner() + await runner.run(task) + + +async def test_diarization_no_format(): + """Test 10: Diarization enabled without formatting.""" + logger.info("=" * 80) + logger.info("TEST 10: Diarization Enabled (No Formatting)") + logger.info("=" * 80) + + connection_params = AssemblyAIConnectionParams( + speech_model="u3-rt-pro", speaker_labels=True + ) + + task, transport = await create_basic_voice_agent(connection_params) + + logger.info("✅ Service created with speaker_labels=True") + logger.info("Expected: Speaker IDs in user_id field, plain text in transcript") + logger.info("Have multiple people speak to see different speaker labels") + + runner = PipelineRunner() + await runner.run(task) + + +async def test_diarization_xml_format(): + """Test 11: Diarization with XML formatting.""" + logger.info("=" * 80) + logger.info("TEST 11: Diarization with XML Formatting") + logger.info("=" * 80) + + connection_params = AssemblyAIConnectionParams( + speech_model="u3-rt-pro", speaker_labels=True + ) + + task, transport = await create_basic_voice_agent( + connection_params, speaker_format="<{speaker}>{text}" + ) + + logger.info("✅ Service created with XML speaker formatting") + logger.info("Expected: Text like 'Hello'") + logger.info("Have multiple people speak to see formatted speaker tags") + + runner = PipelineRunner() + await runner.run(task) + + +async def test_dynamic_keyterms(): + """Test 13: Dynamic keyterms updates.""" + logger.info("=" * 80) + logger.info("TEST 13: Dynamic Keyterms Updates") + logger.info("=" * 80) + + connection_params = AssemblyAIConnectionParams(speech_model="u3-rt-pro") + + task, transport = await create_basic_voice_agent(connection_params) + + async def update_keyterms_stages(): + """Simulate multi-stage conversation with keyterms updates.""" + await asyncio.sleep(5) # Wait for connection + + # Stage 1: Greeting + logger.info("🔄 STAGE 1: Greeting (general terms)") + update1 = STTUpdateSettingsFrame( + settings={"keyterms_prompt": ["hello", "hi", "good morning", "welcome"]} + ) + await task.queue_frames([update1]) + + await asyncio.sleep(10) + + # Stage 2: Name collection + logger.info("🔄 STAGE 2: Name Collection") + update2 = STTUpdateSettingsFrame( + settings={ + "keyterms_prompt": [ + "first name", + "last name", + "John", + "Jane", + "Smith", + "Johnson", + ] + } + ) + await task.queue_frames([update2]) + + await asyncio.sleep(10) + + # Stage 3: Medical info + logger.info("🔄 STAGE 3: Medical Information") + update3 = STTUpdateSettingsFrame( + settings={ + "keyterms_prompt": [ + "cardiology", + "echocardiogram", + "blood pressure", + "Dr. Smith", + "metoprolol", + ] + } + ) + await task.queue_frames([update3]) + + await asyncio.sleep(10) + + # Stage 4: Clear keyterms + logger.info("🔄 STAGE 4: Clear Keyterms") + update4 = STTUpdateSettingsFrame(settings={"keyterms_prompt": []}) + await task.queue_frames([update4]) + + # Start update task + asyncio.create_task(update_keyterms_stages()) + + logger.info("✅ Service created, will update keyterms every 10 seconds") + logger.info("Expected: Different keyterms at each stage") + logger.info("Watch logs for 'STAGE X' messages and test relevant terms") + + runner = PipelineRunner() + await runner.run(task) + + +async def test_dynamic_silence_params(): + """Test 15: Dynamic silence parameter updates.""" + logger.info("=" * 80) + logger.info("TEST 15: Dynamic Silence Parameters") + logger.info("=" * 80) + + connection_params = AssemblyAIConnectionParams(speech_model="u3-rt-pro") + + task, transport = await create_basic_voice_agent(connection_params) + + async def update_silence_params(): + """Update silence parameters for different scenarios.""" + await asyncio.sleep(5) + + # Normal conversation + logger.info("🔄 PHASE 1: Normal conversation (default timing)") + await asyncio.sleep(10) + + # Reading credit card + logger.info("🔄 PHASE 2: Reading numbers (longer silence tolerance)") + update1 = STTUpdateSettingsFrame( + settings={ + "max_turn_silence": 5000, + "min_turn_silence": 300, + } + ) + await task.queue_frames([update1]) + + await asyncio.sleep(15) + + # Back to normal + logger.info("🔄 PHASE 3: Back to normal conversation") + update2 = STTUpdateSettingsFrame( + settings={ + "max_turn_silence": 1200, + "min_turn_silence": 100, + } + ) + await task.queue_frames([update2]) + + asyncio.create_task(update_silence_params()) + + logger.info("✅ Service will update silence parameters during conversation") + logger.info("Expected: Longer pauses tolerated in Phase 2") + logger.info("Try pausing between words to test") + + runner = PipelineRunner() + await runner.run(task) + + +async def test_multi_param_update(): + """Test 17: Update multiple parameters at once.""" + logger.info("=" * 80) + logger.info("TEST 17: Multiple Parameter Update") + logger.info("=" * 80) + + connection_params = AssemblyAIConnectionParams(speech_model="u3-rt-pro") + + task, transport = await create_basic_voice_agent(connection_params) + + async def multi_update(): + await asyncio.sleep(5) + + logger.info("🔄 Updating multiple parameters together") + update = STTUpdateSettingsFrame( + settings={ + "keyterms_prompt": ["account", "routing", "number"], + "max_turn_silence": 3000, + "min_turn_silence": 200, + } + ) + await task.queue_frames([update]) + + logger.info("✅ Check logs for single UpdateConfiguration message") + + asyncio.create_task(multi_update()) + + logger.info("Expected: All params updated in single WebSocket message") + + runner = PipelineRunner() + await runner.run(task) + + +# ============================================================================ +# Main Test Runner +# ============================================================================ + + +def main(): + """Main test runner.""" + parser = argparse.ArgumentParser(description="Test AssemblyAI u3-rt-pro integration") + parser.add_argument( + "--test", + type=str, + default="basic", + help="Test to run (basic, custom_min, max_warning, prompt_warning, " + "prompt_keyterms_conflict, keyterms, diarization, diarization_xml, " + "dynamic_keyterms, dynamic_silence, multi_param, all)", + ) + parser.add_argument( + "--interactive", action="store_true", help="Run in interactive mode" + ) + + args = parser.parse_args() + + # Validate environment + if not TestConfig.validate(): + logger.error("Please set all required environment variables in .env") + sys.exit(1) + + # Test mapping + tests = { + "basic": test_basic_config, + "custom_min": test_custom_min_silence, + "max_warning": test_max_silence_warning, + "prompt_warning": test_custom_prompt_warning, + "prompt_keyterms_conflict": test_prompt_keyterms_conflict, + "keyterms": test_keyterms_basic, + "diarization": test_diarization_no_format, + "diarization_xml": test_diarization_xml_format, + "dynamic_keyterms": test_dynamic_keyterms, + "dynamic_silence": test_dynamic_silence_params, + "multi_param": test_multi_param_update, + } + + if args.interactive: + logger.info("Interactive mode - select test to run:") + for i, (name, _) in enumerate(tests.items(), 1): + logger.info(f"{i}. {name}") + logger.info(f"{len(tests) + 1}. Run all tests") + + choice = input("\nEnter test number: ") + try: + choice_num = int(choice) + if choice_num == len(tests) + 1: + args.test = "all" + else: + args.test = list(tests.keys())[choice_num - 1] + except (ValueError, IndexError): + logger.error("Invalid choice") + sys.exit(1) + + # Run test(s) + if args.test == "all": + logger.info("Running all tests sequentially...") + for test_name, test_func in tests.items(): + try: + asyncio.run(test_func()) + except KeyboardInterrupt: + logger.info(f"Test '{test_name}' interrupted") + break + except Exception as e: + logger.error(f"Test '{test_name}' failed: {e}") + else: + if args.test not in tests: + logger.error(f"Unknown test: {args.test}") + logger.info(f"Available tests: {', '.join(tests.keys())}") + sys.exit(1) + + try: + asyncio.run(tests[args.test]()) + except KeyboardInterrupt: + logger.info("Test interrupted") + except Exception as e: + logger.error(f"Test failed: {e}") + raise + + +if __name__ == "__main__": + main() From 07ae4b8d385f3f67b32bc06548382fc1bcade3e2 Mon Sep 17 00:00:00 2001 From: zack Date: Sun, 1 Mar 2026 11:27:31 -0500 Subject: [PATCH 12/68] Update AssemblyAI examples to use u3-rt-pro and improve 55d example - Update 13d-assemblyai-transcription.py to explicitly use u3-rt-pro model - Update 55d-update-settings-assemblyai-stt.py to demonstrate keyterms updates instead of language updates - Add helpful logging to show before/after keyterms boosting effect - Use difficult names (Xiomara, Saoirse, Krzystof) to demonstrate boosting effectiveness --- .../13d-assemblyai-transcription.py | 4 +++ .../55d-update-settings-assemblyai-stt.py | 25 ++++++++++++++----- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/examples/foundational/13d-assemblyai-transcription.py b/examples/foundational/13d-assemblyai-transcription.py index 06ea52cd5..2dcbaf59b 100644 --- a/examples/foundational/13d-assemblyai-transcription.py +++ b/examples/foundational/13d-assemblyai-transcription.py @@ -16,6 +16,7 @@ from pipecat.pipeline.task import PipelineTask from pipecat.processors.frame_processor import FrameDirection, FrameProcessor from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport +from pipecat.services.assemblyai.models import AssemblyAIConnectionParams from pipecat.services.assemblyai.stt import AssemblyAISTTService from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.daily.transport import DailyParams @@ -49,6 +50,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): stt = AssemblyAISTTService( api_key=os.getenv("ASSEMBLYAI_API_KEY"), + connection_params=AssemblyAIConnectionParams( + speech_model="u3-rt-pro", + ), ) tl = TranscriptionLogger() diff --git a/examples/foundational/55d-update-settings-assemblyai-stt.py b/examples/foundational/55d-update-settings-assemblyai-stt.py index d37c3ec7b..b0d676e25 100644 --- a/examples/foundational/55d-update-settings-assemblyai-stt.py +++ b/examples/foundational/55d-update-settings-assemblyai-stt.py @@ -22,10 +22,10 @@ from pipecat.processors.aggregators.llm_response_universal import ( ) from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport +from pipecat.services.assemblyai.models import AssemblyAIConnectionParams from pipecat.services.assemblyai.stt import AssemblyAISTTService, AssemblyAISTTSettings from pipecat.services.cartesia.tts import CartesiaTTSService from pipecat.services.openai.llm import OpenAILLMService -from pipecat.transcriptions.language import Language from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.daily.transport import DailyParams from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams @@ -51,7 +51,12 @@ transport_params = { async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): logger.info(f"Starting bot") - stt = AssemblyAISTTService(api_key=os.getenv("ASSEMBLYAI_API_KEY")) + stt = AssemblyAISTTService( + api_key=os.getenv("ASSEMBLYAI_API_KEY"), + connection_params=AssemblyAIConnectionParams( + speech_model="u3-rt-pro", + ), + ) tts = CartesiaTTSService( api_key=os.getenv("CARTESIA_API_KEY"), @@ -63,7 +68,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): messages = [ { "role": "system", - "content": "You are a helpful LLM in a WebRTC call. Your goal is to demonstrate your capabilities in a succinct way. Your output will be spoken aloud, so avoid special characters that can't easily be spoken, such as emojis or bullet points. Respond to what the user said in a creative and helpful way.", + "content": "You are a helpful LLM in a WebRTC call demonstrating dynamic keyterms updates. Your goal is to demonstrate your capabilities in a succinct way. Your output will be spoken aloud, so avoid special characters that can't easily be spoken, such as emojis or bullet points. Try saying difficult names like 'Xiomara', 'Saoirse', or 'Krzystof' to test transcription accuracy.", }, ] @@ -97,14 +102,22 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): @transport.event_handler("on_client_connected") async def on_client_connected(transport, client): logger.info(f"Client connected") + logger.info("Phase 1: No keyterms boosting - try saying 'Xiomara', 'Saoirse', or 'Krzystof'") messages.append({"role": "system", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) - await asyncio.sleep(10) - logger.info("Updating AssemblyAI STT settings: language=es") + await asyncio.sleep(15) + logger.info("🔄 Updating keyterms: Adding difficult names for boosting") await task.queue_frame( - STTUpdateSettingsFrame(delta=AssemblyAISTTSettings(language=Language.ES)) + STTUpdateSettingsFrame( + delta=AssemblyAISTTSettings( + connection_params=AssemblyAIConnectionParams( + keyterms_prompt=["Xiomara", "Saoirse", "Krzystof", "Nguyen", "Pipecat"] + ) + ) + ) ) + logger.info("Phase 2: Keyterms active - same names should transcribe better now!") @transport.event_handler("on_client_disconnected") async def on_client_disconnected(transport, client): From 66fca7e3822f75e2042e7cd9e2010c94d0242339 Mon Sep 17 00:00:00 2001 From: zack Date: Sun, 1 Mar 2026 11:33:22 -0500 Subject: [PATCH 13/68] Add backward compatibility for min_end_of_turn_silence_when_confident parameter - Keep old parameter name for backward compatibility - Add deprecation warning when old parameter is used - Automatically migrate old parameter value to new min_turn_silence parameter - Exclude deprecated parameter from WebSocket URL to avoid sending it to API - New parameter takes precedence if both are set --- src/pipecat/services/assemblyai/models.py | 20 +++++++++++++++++++- src/pipecat/services/assemblyai/stt.py | 3 +++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/pipecat/services/assemblyai/models.py b/src/pipecat/services/assemblyai/models.py index f92b1b8bf..d8df07899 100644 --- a/src/pipecat/services/assemblyai/models.py +++ b/src/pipecat/services/assemblyai/models.py @@ -11,8 +11,9 @@ transcription WebSocket messages and connection configuration. """ from typing import List, Literal, Optional +import warnings -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator class Word(BaseModel): @@ -130,6 +131,7 @@ class AssemblyAIConnectionParams(BaseModel): word_finalization_max_wait_time: Maximum time to wait for word finalization in milliseconds. end_of_turn_confidence_threshold: Confidence threshold for end-of-turn detection. min_turn_silence: Minimum silence duration when confident about end-of-turn. + min_end_of_turn_silence_when_confident: DEPRECATED. Use min_turn_silence instead. max_turn_silence: Maximum silence duration before forcing end-of-turn. keyterms_prompt: List of key terms to guide transcription. Will be JSON serialized before sending. prompt: Optional text prompt to guide the transcription. Only used when speech_model is "u3-rt-pro". @@ -149,6 +151,7 @@ class AssemblyAIConnectionParams(BaseModel): word_finalization_max_wait_time: Optional[int] = None end_of_turn_confidence_threshold: Optional[float] = None min_turn_silence: Optional[int] = None + min_end_of_turn_silence_when_confident: Optional[int] = None # Deprecated max_turn_silence: Optional[int] = None keyterms_prompt: Optional[List[str]] = None prompt: Optional[str] = None @@ -158,3 +161,18 @@ class AssemblyAIConnectionParams(BaseModel): language_detection: Optional[bool] = None format_turns: bool = True speaker_labels: Optional[bool] = None + + @model_validator(mode="after") + def handle_deprecated_param(self): + """Handle deprecated min_end_of_turn_silence_when_confident parameter.""" + if self.min_end_of_turn_silence_when_confident is not None: + warnings.warn( + "The 'min_end_of_turn_silence_when_confident' parameter is deprecated and will be " + "removed in a future version. Please use 'min_turn_silence' instead.", + DeprecationWarning, + stacklevel=2, + ) + # If min_turn_silence is not set, use the deprecated value + if self.min_turn_silence is None: + self.min_turn_silence = self.min_end_of_turn_silence_when_confident + return self diff --git a/src/pipecat/services/assemblyai/stt.py b/src/pipecat/services/assemblyai/stt.py index d82b38a99..7e2378408 100644 --- a/src/pipecat/services/assemblyai/stt.py +++ b/src/pipecat/services/assemblyai/stt.py @@ -462,6 +462,9 @@ class AssemblyAISTTService(WebsocketSTTService): """Build WebSocket URL with query parameters using urllib.parse.urlencode.""" params = {} for k, v in self._settings.connection_params.model_dump().items(): + # Skip deprecated parameter - it's been migrated to min_turn_silence + if k == "min_end_of_turn_silence_when_confident": + continue if v is not None: if k == "keyterms_prompt": params[k] = json.dumps(v) From d1cbc811083cf2ebfffe2c66c498647b0027fe55 Mon Sep 17 00:00:00 2001 From: zack Date: Sun, 1 Mar 2026 11:36:46 -0500 Subject: [PATCH 14/68] Fix 07o example to use new min_turn_silence parameter name in docs and comments --- examples/foundational/07o-interruptible-assemblyai-stt.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/foundational/07o-interruptible-assemblyai-stt.py b/examples/foundational/07o-interruptible-assemblyai-stt.py index ee2994c0e..2765f8590 100644 --- a/examples/foundational/07o-interruptible-assemblyai-stt.py +++ b/examples/foundational/07o-interruptible-assemblyai-stt.py @@ -66,8 +66,8 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): - More natural turn detection based on speech patterns and pauses 2. Advanced Turn Detection Tuning (STT Mode) - - `min_turn_silence`: Minimum silence (ms) when confident - about end-of-turn. Lower values = faster responses. Default: 200ms + - `min_turn_silence`: Minimum silence (ms) when confident about end-of-turn. + Lower values = faster responses. Default: 100ms - `max_turn_silence`: Maximum silence (ms) before forcing end-of-turn. Prevents long pauses. Default: 1000ms From 5de495cc989adf7ae2d2624c9e6613978f69b77c Mon Sep 17 00:00:00 2001 From: zack Date: Sun, 1 Mar 2026 11:39:00 -0500 Subject: [PATCH 15/68] Use logger.warning instead of warnings.warn for deprecation message - Makes deprecation warning visible in logs without needing Python warning flags - Users will see the warning during normal operation --- src/pipecat/services/assemblyai/models.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/pipecat/services/assemblyai/models.py b/src/pipecat/services/assemblyai/models.py index d8df07899..3a022dce1 100644 --- a/src/pipecat/services/assemblyai/models.py +++ b/src/pipecat/services/assemblyai/models.py @@ -11,8 +11,8 @@ transcription WebSocket messages and connection configuration. """ from typing import List, Literal, Optional -import warnings +from loguru import logger from pydantic import BaseModel, ConfigDict, Field, model_validator @@ -166,11 +166,9 @@ class AssemblyAIConnectionParams(BaseModel): def handle_deprecated_param(self): """Handle deprecated min_end_of_turn_silence_when_confident parameter.""" if self.min_end_of_turn_silence_when_confident is not None: - warnings.warn( + logger.warning( "The 'min_end_of_turn_silence_when_confident' parameter is deprecated and will be " - "removed in a future version. Please use 'min_turn_silence' instead.", - DeprecationWarning, - stacklevel=2, + "removed in a future version. Please use 'min_turn_silence' instead." ) # If min_turn_silence is not set, use the deprecated value if self.min_turn_silence is None: From 42f91a905613e7b481489cb6437df87821a81e95 Mon Sep 17 00:00:00 2001 From: zack Date: Sun, 1 Mar 2026 11:44:37 -0500 Subject: [PATCH 16/68] Apply ruff formatting fixes --- .../55d-update-settings-assemblyai-stt.py | 4 +- src/pipecat/services/assemblyai/stt.py | 7 +- test_assemblyai_custom.py | 32 ++------ test_assemblyai_interactive.py | 77 +++++++++---------- test_assemblyai_u3pro.py | 17 ++-- 5 files changed, 56 insertions(+), 81 deletions(-) diff --git a/examples/foundational/55d-update-settings-assemblyai-stt.py b/examples/foundational/55d-update-settings-assemblyai-stt.py index b0d676e25..f57865588 100644 --- a/examples/foundational/55d-update-settings-assemblyai-stt.py +++ b/examples/foundational/55d-update-settings-assemblyai-stt.py @@ -102,7 +102,9 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): @transport.event_handler("on_client_connected") async def on_client_connected(transport, client): logger.info(f"Client connected") - logger.info("Phase 1: No keyterms boosting - try saying 'Xiomara', 'Saoirse', or 'Krzystof'") + logger.info( + "Phase 1: No keyterms boosting - try saying 'Xiomara', 'Saoirse', or 'Krzystof'" + ) messages.append({"role": "system", "content": "Please introduce yourself to the user."}) await task.queue_frames([LLMRunFrame()]) diff --git a/src/pipecat/services/assemblyai/stt.py b/src/pipecat/services/assemblyai/stt.py index 7e2378408..9938afdee 100644 --- a/src/pipecat/services/assemblyai/stt.py +++ b/src/pipecat/services/assemblyai/stt.py @@ -355,13 +355,10 @@ class AssemblyAISTTService(WebsocketSTTService): if hasattr(conn_params, "min_turn_silence"): if ( old_conn_params is None - or conn_params.min_turn_silence - != old_conn_params.min_turn_silence + or conn_params.min_turn_silence != old_conn_params.min_turn_silence ): if conn_params.min_turn_silence is not None: - update_config["min_turn_silence"] = ( - conn_params.min_turn_silence - ) + update_config["min_turn_silence"] = conn_params.min_turn_silence logger.info( f"Updating min_turn_silence to: {conn_params.min_turn_silence}ms" ) diff --git a/test_assemblyai_custom.py b/test_assemblyai_custom.py index c406918c0..e8e0a28d2 100755 --- a/test_assemblyai_custom.py +++ b/test_assemblyai_custom.py @@ -48,11 +48,11 @@ async def main(): logger.remove(0) logger.add(sys.stderr, level=LOG_LEVEL) - logger.info("="*80) + logger.info("=" * 80) logger.info("AssemblyAI u3-rt-pro Custom Test") - logger.info("="*80) + logger.info("=" * 80) logger.info("Starting bot... Speak after you hear the greeting!") - logger.info("="*80) + logger.info("=" * 80) # Create local audio transport transport = LocalAudioTransport( @@ -74,78 +74,63 @@ async def main(): speech_model="u3-rt-pro", # speech_model="universal-streaming-english", # speech_model="universal-streaming-multilingual", - # ==================================================================== # Turn Detection Timing # ==================================================================== - # Minimum silence when confident about end of turn (milliseconds) # Default: 100ms | Higher = more patient | Lower = faster responses # Only used in Pipecat mode (vad_force_turn_endpoint=True) min_turn_silence=100000, # min_turn_silence=200, # min_turn_silence=300, - # Maximum turn silence (milliseconds) # WARNING: In Pipecat mode (vad_force_turn_endpoint=True), this is # automatically set equal to min_turn_silence # to avoid double turn detection. Only used as-is in STT mode. max_turn_silence=500, - # End of turn confidence threshold (0.0 to 1.0) # Higher = requires more confidence before ending turn # end_of_turn_confidence_threshold=0.8, - # ==================================================================== # Prompting & Boosting # ==================================================================== - # Custom Prompt (WARNING: test carefully, default is optimized!) # None = Use AssemblyAI's optimized default (recommended for 88% accuracy) prompt=None, # prompt="Transcribe speech with focus on technical terms.", # prompt="Context: Medical conversation. Transcribe accurately.", - # Keyterms Prompting (boosts recognition for specific words) # NOTE: Cannot use both prompt and keyterms_prompt! keyterms_prompt=None, # keyterms_prompt=["Pipecat", "AssemblyAI", "OpenAI", "Cartesia"], # keyterms_prompt=["Python", "JavaScript", "TypeScript", "API"], - # ==================================================================== # Diarization (Speaker Identification) # ==================================================================== - # Enable speaker labels (identifies different speakers) speaker_labels=None, # None or True # speaker_labels=True, - # ==================================================================== # Audio Configuration # ==================================================================== - # Audio sample rate (Hz) # sample_rate=16000, # sample_rate=8000, - # Audio encoding format # encoding="pcm_s16le", # Default: 16-bit PCM # encoding="pcm_mulaw", # μ-law encoding (telephony) - # ==================================================================== # Other Options # ==================================================================== - # Format transcript turns (applies formatting rules) # format_turns=True, # Default # format_turns=False, - # Language detection (only for universal-streaming-multilingual) # language_detection=True, ) # Log connection parameters for debugging - logger.info("="*80) + logger.info("=" * 80) logger.info("CONNECTION PARAMETERS:") logger.info(f" speech_model: {connection_params.speech_model}") logger.info(f" min_turn_silence: {connection_params.min_turn_silence}") @@ -156,27 +141,26 @@ async def main(): logger.info(f" keyterms_prompt: {connection_params.keyterms_prompt}") logger.info(f" speaker_labels: {connection_params.speaker_labels}") logger.info(f" format_turns: {connection_params.format_turns}") - logger.info(f" end_of_turn_confidence_threshold: {connection_params.end_of_turn_confidence_threshold}") + logger.info( + f" end_of_turn_confidence_threshold: {connection_params.end_of_turn_confidence_threshold}" + ) logger.info(f" language_detection: {connection_params.language_detection}") - logger.info("="*80) + logger.info("=" * 80) # AssemblyAI Speech-to-Text Service stt = AssemblyAISTTService( api_key=os.getenv("ASSEMBLYAI_API_KEY"), connection_params=connection_params, - # Turn Detection Mode # True = Pipecat mode (VAD + Smart Turn controls turns) # False = STT mode (u3-rt-pro model controls turns) vad_force_turn_endpoint=True, - # Speaker Formatting (only used if speaker_labels=True) # None = Just log speaker IDs, don't modify transcript speaker_format=None, # speaker_format="{text}", # speaker_format="{speaker}: {text}", # speaker_format="[{speaker}] {text}", - # Additional available parameters (uncomment to use): # should_interrupt=True, # Only for STT mode ) diff --git a/test_assemblyai_interactive.py b/test_assemblyai_interactive.py index ce468ab3d..c5ec0b429 100755 --- a/test_assemblyai_interactive.py +++ b/test_assemblyai_interactive.py @@ -52,11 +52,11 @@ async def run_bot( test_dynamic_updates: Optional[callable] = None, ): """Run the voice bot with specified configuration.""" - logger.info("="*80) + logger.info("=" * 80) logger.info(f"TEST: {test_name}") - logger.info("="*80) + logger.info("=" * 80) logger.info("Starting bot... Speak into your microphone after you hear the greeting!") - logger.info("="*80) + logger.info("=" * 80) # Create local audio transport transport = LocalAudioTransport( @@ -150,6 +150,7 @@ async def run_bot( # === BASIC CONFIGURATION (1-3) === + async def test_01_basic_100ms(): """Test 1: Basic default configuration (100ms).""" connection_params = AssemblyAIConnectionParams( @@ -179,6 +180,7 @@ async def test_03_custom_500ms(): # === PROMPTING & WARNINGS (4-7) === + async def test_04_max_warning(): """Test 4: max_turn_silence warning (should be overridden).""" logger.warning("⚠️ EXPECT WARNING: max_turn_silence will be overridden") @@ -230,6 +232,7 @@ async def test_07_keyterms_difficult(): # === DIARIZATION (8-9) === + async def test_08_diarization_basic(): """Test 8: Basic diarization (speaker IDs logged).""" connection_params = AssemblyAIConnectionParams( @@ -258,6 +261,7 @@ async def test_09_diarization_xml(): # === DYNAMIC UPDATES - SINGLE PARAMETER (10-13) === + async def test_10_dynamic_keyterms(): """Test 10: Dynamic keyterms update with difficult names.""" connection_params = AssemblyAIConnectionParams( @@ -265,16 +269,16 @@ async def test_10_dynamic_keyterms(): ) async def dynamic_update(task): - logger.info("\n" + "="*80) + logger.info("\n" + "=" * 80) logger.info("PHASE 1: No keyterms boosting") logger.info(" Try saying: Xiomara, Saoirse, Krzystof") logger.info(" (May not transcribe correctly)") - logger.info("="*80) + logger.info("=" * 80) await asyncio.sleep(15) - logger.info("\n" + "="*80) + logger.info("\n" + "=" * 80) logger.info("🔄 UPDATING: Adding keyterms boost") - logger.info("="*80) + logger.info("=" * 80) await task.queue_frame( STTUpdateSettingsFrame( delta=AssemblyAISTTSettings( @@ -284,11 +288,11 @@ async def test_10_dynamic_keyterms(): ) ) ) - logger.info("\n" + "="*80) + logger.info("\n" + "=" * 80) logger.info("PHASE 2: Keyterms NOW boosted") logger.info(" Say the same names again: Xiomara, Saoirse, Krzystof") logger.info(" (Should transcribe better now!)") - logger.info("="*80) + logger.info("=" * 80) logger.info("🔄 This test has 2 phases:") logger.info(" Phase 1 (15s): No boosting - names may be wrong") @@ -308,29 +312,27 @@ async def test_11_dynamic_silence(): ) async def dynamic_update(task): - logger.info("\n" + "="*80) + logger.info("\n" + "=" * 80) logger.info("PHASE 1: Quick responses (100ms silence threshold)") logger.info(" Speak normally - bot responds quickly") - logger.info("="*80) + logger.info("=" * 80) await asyncio.sleep(10) - logger.info("\n" + "="*80) + logger.info("\n" + "=" * 80) logger.info("🔄 UPDATING: Changing silence from 100ms → 3000ms (3 seconds!)") - logger.info("="*80) + logger.info("=" * 80) await task.queue_frame( STTUpdateSettingsFrame( delta=AssemblyAISTTSettings( - connection_params=AssemblyAIConnectionParams( - min_turn_silence=3000 - ) + connection_params=AssemblyAIConnectionParams(min_turn_silence=3000) ) ) ) - logger.info("\n" + "="*80) + logger.info("\n" + "=" * 80) logger.info("PHASE 2: Patient responses (3 second silence threshold)") logger.info(" Bot will wait 3 full seconds before responding") logger.info(" Try pausing mid-sentence - bot should NOT interrupt") - logger.info("="*80) + logger.info("=" * 80) logger.info("🔄 Dramatic change: 100ms → 3000ms after 10 seconds") await run_bot( @@ -347,16 +349,16 @@ async def test_12_dynamic_prompt(): ) async def dynamic_update(task): - logger.info("\n" + "="*80) + logger.info("\n" + "=" * 80) logger.info("PHASE 1: Default prompt (no keyterms)") logger.info(" Try saying: Xiomara, Saoirse, Krzystof") logger.info(" (May not transcribe correctly)") - logger.info("="*80) + logger.info("=" * 80) await asyncio.sleep(15) - logger.info("\n" + "="*80) + logger.info("\n" + "=" * 80) logger.info("🔄 UPDATING: Adding custom prompt with keyterms") - logger.info("="*80) + logger.info("=" * 80) custom_prompt = """Transcribe verbatim. Rules: 1) Always include punctuation in output. 2) Use period/question mark ONLY for complete sentences. @@ -368,17 +370,15 @@ Pay special attention to these names and transcribe them exactly: Xiomara, Saoir await task.queue_frame( STTUpdateSettingsFrame( delta=AssemblyAISTTSettings( - connection_params=AssemblyAIConnectionParams( - prompt=custom_prompt - ) + connection_params=AssemblyAIConnectionParams(prompt=custom_prompt) ) ) ) - logger.info("\n" + "="*80) + logger.info("\n" + "=" * 80) logger.info("PHASE 2: Prompt with keyterms NOW active") logger.info(" Say the same names again: Xiomara, Saoirse, Krzystof") logger.info(" (Should transcribe better now!)") - logger.info("="*80) + logger.info("=" * 80) logger.info("🔄 This test has 2 phases:") logger.info(" Phase 1 (15s): Default prompt - names may be wrong") @@ -403,9 +403,7 @@ async def test_13_dynamic_clear_keyterms(): await task.queue_frame( STTUpdateSettingsFrame( delta=AssemblyAISTTSettings( - connection_params=AssemblyAIConnectionParams( - keyterms_prompt=[] - ) + connection_params=AssemblyAIConnectionParams(keyterms_prompt=[]) ) ) ) @@ -421,6 +419,7 @@ async def test_13_dynamic_clear_keyterms(): # === DYNAMIC UPDATES - MULTIPLE PARAMETERS (14-15) === + async def test_14_multi_param_update(): """Test 14: Update multiple parameters at once.""" connection_params = AssemblyAIConnectionParams( @@ -464,9 +463,7 @@ async def test_15_complex_sequence(): await task.queue_frame( STTUpdateSettingsFrame( delta=AssemblyAISTTSettings( - connection_params=AssemblyAIConnectionParams( - keyterms_prompt=["Pipecat"] - ) + connection_params=AssemblyAIConnectionParams(keyterms_prompt=["Pipecat"]) ) ) ) @@ -476,9 +473,7 @@ async def test_15_complex_sequence(): await task.queue_frame( STTUpdateSettingsFrame( delta=AssemblyAISTTSettings( - connection_params=AssemblyAIConnectionParams( - min_turn_silence=200 - ) + connection_params=AssemblyAIConnectionParams(min_turn_silence=200) ) ) ) @@ -506,6 +501,7 @@ async def test_15_complex_sequence(): # === MODE COMPARISON (16-17) === + async def test_16_pipecat_mode(): """Test 16: Pipecat mode (VAD + Smart Turn controls turns).""" connection_params = AssemblyAIConnectionParams( @@ -538,6 +534,7 @@ async def test_17_stt_mode(): # === STT MODE TIMING EXPERIMENTS (18-20) === + async def test_18_stt_long_max_short_min(): """Test 18: STT mode - Long max_turn_silence + Short min (5000ms + 100ms).""" connection_params = AssemblyAIConnectionParams( @@ -596,6 +593,7 @@ async def test_20_stt_both_short(): # === EDGE CASES (21-23) === + async def test_21_very_long_silence(): """Test 21: Very long silence threshold (STT mode only).""" connection_params = AssemblyAIConnectionParams( @@ -645,9 +643,9 @@ async def test_23_keyterms_plus_diarization(): def show_menu(): """Display the comprehensive test menu.""" - print("\n" + "="*80) + print("\n" + "=" * 80) print("AssemblyAI u3-rt-pro Comprehensive Test Suite") - print("="*80) + print("=" * 80) print("\n📋 BASIC CONFIGURATION (1-3)") print(" 1. Basic Default (100ms)") print(" 2. Custom Silence (200ms)") @@ -688,7 +686,7 @@ def show_menu(): print(" 23. Keyterms + Diarization Combined") print("\n 0. Exit") - print("\n" + "="*80) + print("\n" + "=" * 80) async def main(): @@ -735,6 +733,7 @@ async def main(): except Exception as e: logger.error(f"Test failed with error: {e}") import traceback + traceback.print_exc() input("\n\nPress Enter to return to menu...") diff --git a/test_assemblyai_u3pro.py b/test_assemblyai_u3pro.py index ace700753..236ab9b50 100644 --- a/test_assemblyai_u3pro.py +++ b/test_assemblyai_u3pro.py @@ -50,6 +50,7 @@ from pipecat.transports.local.audio import LocalAudioTransport, LocalAudioTransp load_dotenv() + # Test configuration class TestConfig: """Centralized test configuration.""" @@ -205,9 +206,7 @@ async def test_custom_min_silence(): logger.info("TEST 2: Custom min_turn_silence") logger.info("=" * 80) - connection_params = AssemblyAIConnectionParams( - speech_model="u3-rt-pro", min_turn_silence=200 - ) + connection_params = AssemblyAIConnectionParams(speech_model="u3-rt-pro", min_turn_silence=200) task, transport = await create_basic_voice_agent(connection_params) @@ -307,9 +306,7 @@ async def test_diarization_no_format(): logger.info("TEST 10: Diarization Enabled (No Formatting)") logger.info("=" * 80) - connection_params = AssemblyAIConnectionParams( - speech_model="u3-rt-pro", speaker_labels=True - ) + connection_params = AssemblyAIConnectionParams(speech_model="u3-rt-pro", speaker_labels=True) task, transport = await create_basic_voice_agent(connection_params) @@ -327,9 +324,7 @@ async def test_diarization_xml_format(): logger.info("TEST 11: Diarization with XML Formatting") logger.info("=" * 80) - connection_params = AssemblyAIConnectionParams( - speech_model="u3-rt-pro", speaker_labels=True - ) + connection_params = AssemblyAIConnectionParams(speech_model="u3-rt-pro", speaker_labels=True) task, transport = await create_basic_voice_agent( connection_params, speaker_format="<{speaker}>{text}" @@ -516,9 +511,7 @@ def main(): "prompt_keyterms_conflict, keyterms, diarization, diarization_xml, " "dynamic_keyterms, dynamic_silence, multi_param, all)", ) - parser.add_argument( - "--interactive", action="store_true", help="Run in interactive mode" - ) + parser.add_argument("--interactive", action="store_true", help="Run in interactive mode") args = parser.parse_args() From 6968d83ccb1a3e9961df693aadaad1cd0e3636ed Mon Sep 17 00:00:00 2001 From: zack Date: Sun, 1 Mar 2026 11:44:51 -0500 Subject: [PATCH 17/68] Add changelog entries for PR #3856 --- changelog/3856.added.md | 1 + changelog/3856.changed.md | 1 + changelog/3856.fixed.md | 1 + 3 files changed, 3 insertions(+) create mode 100644 changelog/3856.added.md create mode 100644 changelog/3856.changed.md create mode 100644 changelog/3856.fixed.md diff --git a/changelog/3856.added.md b/changelog/3856.added.md new file mode 100644 index 000000000..0d620a925 --- /dev/null +++ b/changelog/3856.added.md @@ -0,0 +1 @@ +Add AssemblyAI u3-rt-pro model support with STT-controlled turn detection mode diff --git a/changelog/3856.changed.md b/changelog/3856.changed.md new file mode 100644 index 000000000..cb714aeba --- /dev/null +++ b/changelog/3856.changed.md @@ -0,0 +1 @@ +Rename AssemblyAI min_end_of_turn_silence_when_confident parameter to min_turn_silence (old name still supported with deprecation warning) diff --git a/changelog/3856.fixed.md b/changelog/3856.fixed.md new file mode 100644 index 000000000..d9a63a692 --- /dev/null +++ b/changelog/3856.fixed.md @@ -0,0 +1 @@ +Add beta feature warning when using custom prompts with AssemblyAI From 36b9c057309955d241751a8efcdb30897caddd73 Mon Sep 17 00:00:00 2001 From: zack Date: Sun, 1 Mar 2026 11:45:24 -0500 Subject: [PATCH 18/68] Fix changelog entries to use proper markdown bullet format --- changelog/3856.added.md | 2 +- changelog/3856.changed.md | 2 +- changelog/3856.fixed.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/changelog/3856.added.md b/changelog/3856.added.md index 0d620a925..95b656058 100644 --- a/changelog/3856.added.md +++ b/changelog/3856.added.md @@ -1 +1 @@ -Add AssemblyAI u3-rt-pro model support with STT-controlled turn detection mode +- Add AssemblyAI u3-rt-pro model support with STT-controlled turn detection mode diff --git a/changelog/3856.changed.md b/changelog/3856.changed.md index cb714aeba..72331c068 100644 --- a/changelog/3856.changed.md +++ b/changelog/3856.changed.md @@ -1 +1 @@ -Rename AssemblyAI min_end_of_turn_silence_when_confident parameter to min_turn_silence (old name still supported with deprecation warning) +- Rename AssemblyAI min_end_of_turn_silence_when_confident parameter to min_turn_silence (old name still supported with deprecation warning) diff --git a/changelog/3856.fixed.md b/changelog/3856.fixed.md index d9a63a692..c31fe8ddf 100644 --- a/changelog/3856.fixed.md +++ b/changelog/3856.fixed.md @@ -1 +1 @@ -Add beta feature warning when using custom prompts with AssemblyAI +- Add beta feature warning when using custom prompts with AssemblyAI From cb7e6127387ee802fcbed9cbaa4a9eabc6bd875f Mon Sep 17 00:00:00 2001 From: zack Date: Sun, 1 Mar 2026 11:51:51 -0500 Subject: [PATCH 19/68] Remove test files and testing documentation from PR --- TESTING_CHECKLIST.md | 273 ------------ TESTING_SETUP.md | 310 -------------- test_assemblyai_custom.py | 240 ----------- test_assemblyai_interactive.py | 749 --------------------------------- test_assemblyai_u3pro.py | 582 ------------------------- 5 files changed, 2154 deletions(-) delete mode 100644 TESTING_CHECKLIST.md delete mode 100644 TESTING_SETUP.md delete mode 100755 test_assemblyai_custom.py delete mode 100755 test_assemblyai_interactive.py delete mode 100644 test_assemblyai_u3pro.py diff --git a/TESTING_CHECKLIST.md b/TESTING_CHECKLIST.md deleted file mode 100644 index 8d2d147d4..000000000 --- a/TESTING_CHECKLIST.md +++ /dev/null @@ -1,273 +0,0 @@ -# AssemblyAI u3-rt-pro Testing Checklist - -## Test Environment Setup -- [ ] Install dependencies: `uv sync --group dev --all-extras` -- [ ] Set up `.env` file with API keys -- [ ] Verify LiveKit connection -- [ ] Run basic voice agent test - ---- - -## Feature Testing Checklist - -### ✅ Basic Configuration Tests - -#### Test 1: Default u3-rt-pro Configuration -- [ ] **Setup:** Create service with default params -- [ ] **Expected:** No errors, uses u3-rt-pro model with 100ms min/max -- [ ] **Verify:** Check logs for connection confirmation - -#### Test 2: Custom min_turn_silence -- [ ] **Setup:** Set `min_turn_silence=200` -- [ ] **Expected:** Both min and max set to 200ms -- [ ] **Verify:** Speak short phrases, observe turn detection timing - -#### Test 3: User sets max_turn_silence (Warning Test) -- [ ] **Setup:** Set `max_turn_silence=500` in connection params -- [ ] **Expected:** Warning logged, value overridden to match min -- [ ] **Verify:** Check logs for warning message - ---- - -### ✅ Prompting Tests - -#### Test 4: No Prompt (Default - Recommended) -- [ ] **Setup:** Don't set prompt parameter -- [ ] **Expected:** Uses default prompt, 88% accuracy, no warnings -- [ ] **Verify:** Transcription quality is good - -#### Test 5: Custom Prompt (Warning Test) -- [ ] **Setup:** Set custom prompt in connection params -- [ ] **Expected:** Warning logged about testing without prompt first -- [ ] **Verify:** Check logs for prompt warning - -#### Test 6: Prompt + Keyterms Conflict (Error Test) -- [ ] **Setup:** Set both `prompt` and `keyterms_prompt` at init -- [ ] **Expected:** ValueError raised with helpful error message -- [ ] **Verify:** Service fails to initialize with clear error - ---- - -### ✅ Keyterms Prompting Tests - -#### Test 7: Basic Keyterms at Init -- [ ] **Setup:** Set `keyterms_prompt=["Pipecat", "AssemblyAI", "Universal-3"]` -- [ ] **Expected:** Terms are boosted in recognition -- [ ] **Verify:** Say the boosted terms, check accuracy - -#### Test 8: Empty Keyterms (No Boosting) -- [ ] **Setup:** Set `keyterms_prompt=[]` -- [ ] **Expected:** No boosting, default behavior -- [ ] **Verify:** Normal transcription - ---- - -### ✅ Diarization Tests - -#### Test 9: Diarization Disabled (Default) -- [ ] **Setup:** Don't set `speaker_labels` parameter -- [ ] **Expected:** No speaker info in transcripts -- [ ] **Verify:** TranscriptionFrame.user_id is default user_id - -#### Test 10: Diarization Enabled (No Formatting) -- [ ] **Setup:** Set `speaker_labels=True` -- [ ] **Expected:** Speaker ID in user_id field, plain text -- [ ] **Verify:** Multiple speakers show different IDs (Speaker A, Speaker B) - -#### Test 11: Diarization with XML Formatting -- [ ] **Setup:** Set `speaker_labels=True`, `speaker_format="<{speaker}>{text}"` -- [ ] **Expected:** Text includes speaker tags: `Hello` -- [ ] **Verify:** Formatted text in transcript, speaker ID in user_id - -#### Test 12: Diarization with Colon Prefix -- [ ] **Setup:** Set `speaker_labels=True`, `speaker_format="{speaker}: {text}"` -- [ ] **Expected:** Text includes prefix: `Speaker A: Hello` -- [ ] **Verify:** Formatted text, multiple speakers distinguishable - ---- - -### ✅ Dynamic Updates Tests - -#### Test 13: Dynamic Keyterms Update (Stage 1 → Stage 2) -- [ ] **Setup:** Start with empty keyterms, update mid-conversation -- [ ] **Expected:** New keyterms take effect immediately -- [ ] **Test Steps:** - 1. Start conversation with no keyterms - 2. Send update frame with `keyterms_prompt=["cardiology", "Dr. Smith"]` - 3. Say the new terms -- [ ] **Verify:** Improved recognition after update - -#### Test 14: Clear Keyterms (Reset Context) -- [ ] **Setup:** Start with keyterms, clear them mid-stream -- [ ] **Expected:** Context biasing removed -- [ ] **Test Steps:** - 1. Start with `keyterms_prompt=["test", "words"]` - 2. Send update frame with `keyterms_prompt=[]` -- [ ] **Verify:** No more boosting after clear - -#### Test 15: Dynamic Silence Parameters -- [ ] **Setup:** Update `max_turn_silence` mid-stream -- [ ] **Expected:** Turn detection timing changes -- [ ] **Test Steps:** - 1. Start with default (1200ms) - 2. Update to `max_turn_silence=5000` (for reading numbers) - 3. Pause longer between words - 4. Update back to `max_turn_silence=1200` -- [ ] **Verify:** Longer pauses tolerated when increased - -#### Test 16: Dynamic Prompt Update -- [ ] **Setup:** Update prompt mid-stream -- [ ] **Expected:** New instructions take effect -- [ ] **Test Steps:** - 1. Start with default prompt - 2. Send update with custom prompt -- [ ] **Verify:** Behavior changes according to new prompt - -#### Test 17: Multiple Parameters at Once -- [ ] **Setup:** Update keyterms, max_turn_silence, and min_end_of_turn together -- [ ] **Expected:** All parameters updated in single WebSocket message -- [ ] **Verify:** Check logs for single UpdateConfiguration message - -#### Test 18: Dynamic Update - Prompt + Keyterms Conflict (Error) -- [ ] **Setup:** Try to update both prompt and keyterms_prompt in same update -- [ ] **Expected:** ValueError raised -- [ ] **Verify:** Update fails with clear error message - ---- - -### ✅ Turn Detection Mode Tests - -#### Test 19: Pipecat Mode (vad_force_turn_endpoint=True) - Default -- [ ] **Setup:** Use default settings (Pipecat mode) -- [ ] **Expected:** - - ForceEndpoint sent on VAD stop - - Smart Turn Analyzer makes decisions - - min=max=100ms for u3-rt-pro -- [ ] **Verify:** Fast finals, Smart Turn handles completeness - -#### Test 20: STT Mode (vad_force_turn_endpoint=False) - u3-rt-pro only -- [ ] **Setup:** Set `vad_force_turn_endpoint=False` with u3-rt-pro -- [ ] **Expected:** - - AssemblyAI controls turn endings - - SpeechStarted message triggers interruptions - - UserStarted/StoppedSpeakingFrame emitted -- [ ] **Verify:** Turn detection from AssemblyAI model - -#### Test 21: STT Mode with universal-streaming (Error Test) -- [ ] **Setup:** Set `vad_force_turn_endpoint=False` with universal-streaming -- [ ] **Expected:** ValueError raised (requires u3-rt-pro) -- [ ] **Verify:** Service fails with clear error - ---- - -### ✅ Language Detection Tests (If Multilingual Model) - -#### Test 22: Language Detection Enabled -- [ ] **Setup:** Use `universal-streaming-multilingual` with `language_detection=True` -- [ ] **Expected:** Language codes in transcripts -- [ ] **Verify:** Speak different languages, check language_code field - -#### Test 23: Language Confidence Threshold -- [ ] **Setup:** Enable language detection -- [ ] **Expected:** High confidence (≥0.7) → detected language, Low → fallback to English -- [ ] **Verify:** Check logs for confidence warnings - ---- - -### ✅ Edge Cases & Error Handling - -#### Test 24: WebSocket Disconnect During Update -- [ ] **Setup:** Simulate disconnect, try update -- [ ] **Expected:** Error logged, update queued for reconnection -- [ ] **Verify:** Graceful handling, no crash - -#### Test 25: Invalid Parameter Types -- [ ] **Setup:** Send update with wrong type (e.g., keyterms_prompt as string) -- [ ] **Expected:** Warning logged, parameter skipped -- [ ] **Verify:** Service continues, invalid param ignored - -#### Test 26: Unknown Parameter in Update -- [ ] **Setup:** Send update with unsupported parameter (e.g., `language`) -- [ ] **Expected:** Warning logged about parameter -- [ ] **Verify:** Other valid params still updated - ---- - -### ✅ Integration Tests - -#### Test 27: Full Voice Agent Flow (Multi-Stage) -- [ ] **Setup:** Complete voice agent with stage transitions -- [ ] **Test Steps:** - 1. Greeting stage (general keyterms) - 2. Name collection stage (name keyterms) - 3. Account number stage (number keyterms, longer silence) - 4. Medical info stage (medical keyterms) - 5. Closing stage (goodbye keyterms) -- [ ] **Verify:** Each stage has appropriate keyterms and timing - -#### Test 28: Diarization + Dynamic Updates -- [ ] **Setup:** Enable diarization, update keyterms mid-stream -- [ ] **Expected:** Both features work together -- [ ] **Verify:** Speaker IDs persist, keyterms update correctly - -#### Test 29: Interruption Handling -- [ ] **Setup:** Bot speaking, user interrupts -- [ ] **Expected:** - - Pipecat mode: VAD + Smart Turn handles - - STT mode: SpeechStarted triggers interrupt -- [ ] **Verify:** Bot stops, user speech processed - ---- - -## Testing Results Template - -``` -| Test # | Feature | Status | Notes | -|--------|---------|--------|-------| -| 1 | Default Config | ✅ PASS | | -| 2 | Custom min_silence | ✅ PASS | | -| 3 | max_silence Warning | ✅ PASS | | -| ... | ... | ... | ... | -``` - ---- - -## Expected Outcomes Summary - -### ✅ Should Work (No Errors) -- Default configuration -- Custom min_turn_silence -- Keyterms prompting -- Diarization with/without formatting -- Dynamic updates (one parameter or multiple) -- Pipecat mode turn detection - -### ⚠️ Should Warn (Logs Warning, Continues) -- Custom prompt set at init -- max_turn_silence set (overridden) -- Invalid parameter types in updates -- Language update attempted -- Prompt used with universal-streaming - -### ❌ Should Error (Raises Exception, Stops) -- prompt + keyterms_prompt at init -- prompt + keyterms_prompt in same update -- vad_force_turn_endpoint=False with universal-streaming - ---- - -## Quick Test Commands - -```bash -# Run basic test -python test_assemblyai_u3pro.py --test basic - -# Run specific test -python test_assemblyai_u3pro.py --test diarization - -# Run all tests -python test_assemblyai_u3pro.py --test all - -# Interactive mode -python test_assemblyai_u3pro.py --interactive -``` diff --git a/TESTING_SETUP.md b/TESTING_SETUP.md deleted file mode 100644 index fa1dca462..000000000 --- a/TESTING_SETUP.md +++ /dev/null @@ -1,310 +0,0 @@ -# AssemblyAI u3-rt-pro Testing Setup Guide - -## Quick Start - -### 1. Setup Environment - -```bash -# Copy API keys -cp .env.testing .env - -# Install dependencies -uv sync --group dev --all-extras --no-extra gstreamer --no-extra krisp - -# Make test script executable -chmod +x test_assemblyai_u3pro.py -``` - -### 2. Ensure Audio Devices - -Make sure you have: -- **Microphone** enabled and working -- **Speakers/headphones** connected -- Audio permissions granted (macOS will prompt on first run) - -### 3. Run Tests - -```bash -# Run a specific test -python test_assemblyai_u3pro.py --test basic - -# Interactive mode (choose from menu) -python test_assemblyai_u3pro.py --interactive - -# Run all tests sequentially -python test_assemblyai_u3pro.py --test all -``` - ---- - -## Available Tests - -### Basic Configuration Tests -```bash -# Test 1: Default configuration (min=max=100ms) -python test_assemblyai_u3pro.py --test basic - -# Test 2: Custom min_turn_silence -python test_assemblyai_u3pro.py --test custom_min - -# Test 3: max_turn_silence warning (should be overridden) -python test_assemblyai_u3pro.py --test max_warning -``` - -### Prompting Tests -```bash -# Test 5: Custom prompt warning -python test_assemblyai_u3pro.py --test prompt_warning - -# Test 6: Prompt + keyterms conflict (should error) -python test_assemblyai_u3pro.py --test prompt_keyterms_conflict - -# Test 7: Basic keyterms prompting -python test_assemblyai_u3pro.py --test keyterms -``` - -### Diarization Tests -```bash -# Test 10: Diarization without formatting -python test_assemblyai_u3pro.py --test diarization - -# Test 11: Diarization with XML formatting -python test_assemblyai_u3pro.py --test diarization_xml -``` - -### Dynamic Updates Tests -```bash -# Test 13: Dynamic keyterms (multi-stage) -python test_assemblyai_u3pro.py --test dynamic_keyterms - -# Test 15: Dynamic silence parameters -python test_assemblyai_u3pro.py --test dynamic_silence - -# Test 17: Multiple parameters at once -python test_assemblyai_u3pro.py --test multi_param -``` - ---- - -## Test Execution Flow - -### For Each Test: - -1. **Start the test script** - ```bash - python test_assemblyai_u3pro.py --test - ``` - -2. **Wait for "started" message** indicating the bot is ready - -3. **Speak into your microphone** to test - the bot will: - - Transcribe your speech (you'll see `📝 TRANSCRIPTION:` logs) - - Process through the LLM - - Respond with voice through your speakers - -4. **Observe logs** for: - - ✅ Success indicators - - ⚠️ Warning messages - - ❌ Error messages - - 📝 Transcription output - -5. **Verify expected behavior** against checklist - -6. **Stop test** with Ctrl+C - ---- - -## Expected Test Outcomes - -### Should Pass (✅) -- Basic configuration creates service -- Custom parameters are applied -- Keyterms boost recognition -- Diarization shows speaker IDs -- Dynamic updates work without errors - -### Should Warn (⚠️) -Check logs for warnings: -- "We recommend testing at first with no prompt" -- "max_turn_silence is not used in Pipecat mode" -- "Unknown setting for AssemblyAI STT service" - -### Should Error (❌) -Should raise ValueError and fail to start: -- Both prompt and keyterms_prompt set at init -- Both prompt and keyterms_prompt in same update -- vad_force_turn_endpoint=False with universal-streaming - ---- - -## Debugging Tips - -### Check Logs -```bash -# Run with verbose logging -LOGURU_LEVEL=DEBUG python test_assemblyai_u3pro.py --test -``` - -### Common Issues - -**Issue: "WebSocket connection failed"** -- Check ASSEMBLYAI_API_KEY is correct -- Verify network connection -- Check firewall settings - -**Issue: "No audio input/output"** -- Verify microphone permissions (System Preferences → Security & Privacy → Microphone) -- Check default audio devices in System Preferences → Sound -- Test microphone with another app first -- Make sure no other app is using the microphone - -**Issue: "No transcriptions appearing"** -- Verify microphone permissions -- Check audio levels (speak louder or move closer to mic) -- Speak clearly and wait for VAD to detect -- Check if microphone is muted - -**Issue: "Can't hear bot responses"** -- Check speaker/headphone volume -- Verify correct output device is selected -- Check terminal for TTS errors - -**Issue: "Service fails to start"** -- Check all API keys in .env -- Run `uv sync` to ensure dependencies installed -- Check Python version (3.10+) - ---- - -## Manual Testing Checklist - -After running automated tests, manually verify: - -### ✅ Audio Quality -- [ ] Transcriptions are accurate -- [ ] No distortion or dropouts -- [ ] Latency is acceptable - -### ✅ Turn Detection -- [ ] Bot waits for user to finish speaking -- [ ] No premature cutoffs -- [ ] Handles natural pauses correctly - -### ✅ Interruptions -- [ ] Can interrupt bot mid-sentence -- [ ] Interruption is smooth -- [ ] Bot stops speaking immediately - -### ✅ Diarization (if enabled) -- [ ] Multiple speakers detected correctly -- [ ] Speaker IDs consistent -- [ ] Speaker formatting works - -### ✅ Dynamic Updates -- [ ] Keyterms update without disconnection -- [ ] Turn detection timing changes work -- [ ] Updates logged correctly - ---- - -## Test Results Recording - -### Use this template: - -```markdown -## Test Run: YYYY-MM-DD - -| Test # | Test Name | Status | Notes | -|--------|-----------|--------|-------| -| 1 | basic | ✅ PASS | Transcriptions working | -| 2 | custom_min | ✅ PASS | Turn timing changed | -| 3 | max_warning | ✅ PASS | Warning logged | -| 5 | prompt_warning | ✅ PASS | Warning shown | -| 6 | prompt_keyterms_conflict | ✅ PASS | ValueError raised | -| 7 | keyterms | ✅ PASS | Terms boosted | -| 10 | diarization | ✅ PASS | Speaker IDs correct | -| 11 | diarization_xml | ✅ PASS | XML tags shown | -| 13 | dynamic_keyterms | ✅ PASS | Updates worked | -| 15 | dynamic_silence | ✅ PASS | Timing adjusted | -| 17 | multi_param | ✅ PASS | All params updated | - -### Issues Found: -- None - -### Notes: -- All tests passed successfully -- Latency is excellent (sub-300ms) -- Diarization accuracy is good -``` - ---- - -## Advanced Testing - -### Custom Test Scenarios - -Create custom tests by modifying `test_assemblyai_u3pro.py`: - -```python -async def test_my_custom_scenario(): - """My custom test scenario.""" - logger.info("Testing my specific use case") - - connection_params = AssemblyAIConnectionParams( - speech_model="u3-rt-pro", - # Your custom params here - ) - - task, transport = await create_basic_voice_agent(connection_params) - - # Your test logic here - - runner = PipelineRunner() - await runner.run(task) -``` - -### Stress Testing - -Test with: -- Multiple simultaneous speakers -- Long conversations (30+ minutes) -- Rapid speech -- Heavy accents -- Background noise -- Poor network conditions - ---- - -## Reporting Issues - -When reporting issues, include: - -1. **Test name and number** -2. **Full error message and stack trace** -3. **Relevant log output** (use LOGURU_LEVEL=DEBUG) -4. **Configuration used** (connection_params) -5. **Expected vs actual behavior** -6. **Steps to reproduce** - ---- - -## Next Steps - -After testing: - -1. ✅ Mark completed tests in `TESTING_CHECKLIST.md` -2. 📝 Document any issues found -3. 🐛 Create GitHub issues for bugs -4. ✨ Suggest improvements -5. 📊 Share results with team - ---- - -## Contact - -Questions? Issues? -- Check `TESTING_CHECKLIST.md` for detailed test descriptions -- Review logs with `LOGURU_LEVEL=DEBUG` -- Reach out to the team with your findings - -Happy testing! 🎯 diff --git a/test_assemblyai_custom.py b/test_assemblyai_custom.py deleted file mode 100755 index e8e0a28d2..000000000 --- a/test_assemblyai_custom.py +++ /dev/null @@ -1,240 +0,0 @@ -#!/usr/bin/env python3 -"""Custom AssemblyAI u3-rt-pro Test Script -Easy parameter tweaking for experimentation - -Edit the CONFIGURATION section below to test different settings! -""" - -import asyncio -import os -import sys - -from dotenv import load_dotenv -from loguru import logger - -from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.frames.frames import LLMRunFrame -from pipecat.pipeline.pipeline import Pipeline -from pipecat.pipeline.runner import PipelineRunner -from pipecat.pipeline.task import PipelineParams, PipelineTask -from pipecat.processors.aggregators.llm_context import LLMContext -from pipecat.processors.aggregators.llm_response_universal import ( - LLMContextAggregatorPair, - LLMUserAggregatorParams, -) -from pipecat.services.assemblyai.models import AssemblyAIConnectionParams -from pipecat.services.assemblyai.stt import AssemblyAISTTService -from pipecat.services.cartesia.tts import CartesiaTTSService -from pipecat.services.openai.llm import OpenAILLMService -from pipecat.transports.local.audio import LocalAudioTransport, LocalAudioTransportParams - -load_dotenv(override=True) - -# ============================================================================ -# CONFIGURATION -# ============================================================================ - -# Log Level: "DEBUG" for detailed logs, "INFO" for normal operation -LOG_LEVEL = "INFO" - -# ============================================================================ -# BOT IMPLEMENTATION -# ============================================================================ - - -async def main(): - """Run the custom test bot with your configured parameters.""" - # Setup logging - logger.remove(0) - logger.add(sys.stderr, level=LOG_LEVEL) - - logger.info("=" * 80) - logger.info("AssemblyAI u3-rt-pro Custom Test") - logger.info("=" * 80) - logger.info("Starting bot... Speak after you hear the greeting!") - logger.info("=" * 80) - - # Create local audio transport - transport = LocalAudioTransport( - LocalAudioTransportParams( - audio_in_enabled=True, - audio_out_enabled=True, - ) - ) - - # ======================================================================== - # EDIT PARAMETERS HERE - # ======================================================================== - - # Build connection params - connection_params = AssemblyAIConnectionParams( - # ==================================================================== - # Model Selection - # ==================================================================== - speech_model="u3-rt-pro", - # speech_model="universal-streaming-english", - # speech_model="universal-streaming-multilingual", - # ==================================================================== - # Turn Detection Timing - # ==================================================================== - # Minimum silence when confident about end of turn (milliseconds) - # Default: 100ms | Higher = more patient | Lower = faster responses - # Only used in Pipecat mode (vad_force_turn_endpoint=True) - min_turn_silence=100000, - # min_turn_silence=200, - # min_turn_silence=300, - # Maximum turn silence (milliseconds) - # WARNING: In Pipecat mode (vad_force_turn_endpoint=True), this is - # automatically set equal to min_turn_silence - # to avoid double turn detection. Only used as-is in STT mode. - max_turn_silence=500, - # End of turn confidence threshold (0.0 to 1.0) - # Higher = requires more confidence before ending turn - # end_of_turn_confidence_threshold=0.8, - # ==================================================================== - # Prompting & Boosting - # ==================================================================== - # Custom Prompt (WARNING: test carefully, default is optimized!) - # None = Use AssemblyAI's optimized default (recommended for 88% accuracy) - prompt=None, - # prompt="Transcribe speech with focus on technical terms.", - # prompt="Context: Medical conversation. Transcribe accurately.", - # Keyterms Prompting (boosts recognition for specific words) - # NOTE: Cannot use both prompt and keyterms_prompt! - keyterms_prompt=None, - # keyterms_prompt=["Pipecat", "AssemblyAI", "OpenAI", "Cartesia"], - # keyterms_prompt=["Python", "JavaScript", "TypeScript", "API"], - # ==================================================================== - # Diarization (Speaker Identification) - # ==================================================================== - # Enable speaker labels (identifies different speakers) - speaker_labels=None, # None or True - # speaker_labels=True, - # ==================================================================== - # Audio Configuration - # ==================================================================== - # Audio sample rate (Hz) - # sample_rate=16000, - # sample_rate=8000, - # Audio encoding format - # encoding="pcm_s16le", # Default: 16-bit PCM - # encoding="pcm_mulaw", # μ-law encoding (telephony) - # ==================================================================== - # Other Options - # ==================================================================== - # Format transcript turns (applies formatting rules) - # format_turns=True, # Default - # format_turns=False, - # Language detection (only for universal-streaming-multilingual) - # language_detection=True, - ) - - # Log connection parameters for debugging - logger.info("=" * 80) - logger.info("CONNECTION PARAMETERS:") - logger.info(f" speech_model: {connection_params.speech_model}") - logger.info(f" min_turn_silence: {connection_params.min_turn_silence}") - logger.info(f" max_turn_silence: {connection_params.max_turn_silence}") - logger.info(f" sample_rate: {connection_params.sample_rate}") - logger.info(f" encoding: {connection_params.encoding}") - logger.info(f" prompt: {connection_params.prompt}") - logger.info(f" keyterms_prompt: {connection_params.keyterms_prompt}") - logger.info(f" speaker_labels: {connection_params.speaker_labels}") - logger.info(f" format_turns: {connection_params.format_turns}") - logger.info( - f" end_of_turn_confidence_threshold: {connection_params.end_of_turn_confidence_threshold}" - ) - logger.info(f" language_detection: {connection_params.language_detection}") - logger.info("=" * 80) - - # AssemblyAI Speech-to-Text Service - stt = AssemblyAISTTService( - api_key=os.getenv("ASSEMBLYAI_API_KEY"), - connection_params=connection_params, - # Turn Detection Mode - # True = Pipecat mode (VAD + Smart Turn controls turns) - # False = STT mode (u3-rt-pro model controls turns) - vad_force_turn_endpoint=True, - # Speaker Formatting (only used if speaker_labels=True) - # None = Just log speaker IDs, don't modify transcript - speaker_format=None, - # speaker_format="{text}", - # speaker_format="{speaker}: {text}", - # speaker_format="[{speaker}] {text}", - # Additional available parameters (uncomment to use): - # should_interrupt=True, # Only for STT mode - ) - - # ======================================================================== - - # Text-to-Speech - tts = CartesiaTTSService( - api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="a0e99841-438c-4a64-b679-ae501e7d6091", # Conversational English - ) - - # LLM - llm = OpenAILLMService( - api_key=os.getenv("OPENAI_API_KEY"), - model="gpt-4", - ) - - # Conversation context - messages = [ - { - "role": "system", - "content": ( - "You are a helpful voice assistant testing the AssemblyAI u3-rt-pro model. " - "Keep responses very brief (1-2 sentences). " - "Start by introducing yourself briefly and asking the user to speak." - ), - }, - ] - - context = LLMContext(messages) - - # Configure aggregator based on mode - # In STT mode, don't use VAD (model handles turn detection) - # In Pipecat mode, use VAD + Smart Turn - vad_force_turn_endpoint = True # Must match the value in stt configuration above - user_params = None - if vad_force_turn_endpoint: - user_params = LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()) - - user_aggregator, assistant_aggregator = LLMContextAggregatorPair( - context, - user_params=user_params, - ) - - # Pipeline - pipeline = Pipeline( - [ - transport.input(), - stt, - user_aggregator, - llm, - tts, - transport.output(), - assistant_aggregator, - ] - ) - - # Task - task = PipelineTask( - pipeline, - params=PipelineParams( - enable_metrics=True, - enable_usage_metrics=True, - ), - ) - - # Start the conversation - await task.queue_frames([LLMRunFrame()]) - - # Run - runner = PipelineRunner() - await runner.run(task) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/test_assemblyai_interactive.py b/test_assemblyai_interactive.py deleted file mode 100755 index c5ec0b429..000000000 --- a/test_assemblyai_interactive.py +++ /dev/null @@ -1,749 +0,0 @@ -#!/usr/bin/env python3 -"""Interactive AssemblyAI u3-rt-pro Comprehensive Test Suite - -Tests all features with detailed scenarios: -- Basic configuration variations -- Prompting and keyterms with difficult names -- Diarization -- Dynamic parameter updates (single and multiple) -- Mode comparisons -- STT mode timing experiments (testing silence parameters) -- Edge cases - -Usage: - python test_assemblyai_interactive.py -""" - -import asyncio -import os -import sys -from typing import Optional - -from dotenv import load_dotenv -from loguru import logger - -from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.frames.frames import LLMRunFrame, STTUpdateSettingsFrame -from pipecat.pipeline.pipeline import Pipeline -from pipecat.pipeline.runner import PipelineRunner -from pipecat.pipeline.task import PipelineParams, PipelineTask -from pipecat.processors.aggregators.llm_context import LLMContext -from pipecat.processors.aggregators.llm_response_universal import ( - LLMContextAggregatorPair, - LLMUserAggregatorParams, -) -from pipecat.services.assemblyai.models import AssemblyAIConnectionParams -from pipecat.services.assemblyai.stt import AssemblyAISTTService, AssemblyAISTTSettings -from pipecat.services.cartesia.tts import CartesiaTTSService -from pipecat.services.openai.llm import OpenAILLMService -from pipecat.transports.local.audio import LocalAudioTransport, LocalAudioTransportParams - -load_dotenv(override=True) - -logger.remove(0) -logger.add(sys.stderr, level="INFO") - - -async def run_bot( - connection_params: AssemblyAIConnectionParams, - test_name: str, - vad_force_turn_endpoint: bool = True, - speaker_format: Optional[str] = None, - test_dynamic_updates: Optional[callable] = None, -): - """Run the voice bot with specified configuration.""" - logger.info("=" * 80) - logger.info(f"TEST: {test_name}") - logger.info("=" * 80) - logger.info("Starting bot... Speak into your microphone after you hear the greeting!") - logger.info("=" * 80) - - # Create local audio transport - transport = LocalAudioTransport( - LocalAudioTransportParams( - audio_in_enabled=True, - audio_out_enabled=True, - ) - ) - - # AssemblyAI Speech-to-Text - stt = AssemblyAISTTService( - api_key=os.getenv("ASSEMBLYAI_API_KEY"), - connection_params=connection_params, - vad_force_turn_endpoint=vad_force_turn_endpoint, - speaker_format=speaker_format, - ) - - # Text-to-Speech - tts = CartesiaTTSService( - api_key=os.getenv("CARTESIA_API_KEY"), - voice_id="a0e99841-438c-4a64-b679-ae501e7d6091", - ) - - # LLM - llm = OpenAILLMService( - api_key=os.getenv("OPENAI_API_KEY"), - model="gpt-4", - ) - - # Conversation context - messages = [ - { - "role": "system", - "content": ( - "You are a helpful voice assistant testing the AssemblyAI u3-rt-pro model. " - "Keep responses very brief (1-2 sentences). " - "Start by introducing yourself briefly and asking the user to speak." - ), - }, - ] - - context = LLMContext(messages) - - # Configure aggregator based on mode - user_params = None - if vad_force_turn_endpoint: - user_params = LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()) - - user_aggregator, assistant_aggregator = LLMContextAggregatorPair( - context, - user_params=user_params, - ) - - # Pipeline - pipeline = Pipeline( - [ - transport.input(), - stt, - user_aggregator, - llm, - tts, - transport.output(), - assistant_aggregator, - ] - ) - - # Task - task = PipelineTask( - pipeline, - params=PipelineParams( - enable_metrics=True, - enable_usage_metrics=True, - ), - ) - - # Handle dynamic updates if provided - if test_dynamic_updates: - asyncio.create_task(test_dynamic_updates(task)) - - # Start the conversation - await task.queue_frames([LLMRunFrame()]) - - # Run - runner = PipelineRunner() - await runner.run(task) - - -# ============================================================================ -# Test Configurations -# ============================================================================ - -# === BASIC CONFIGURATION (1-3) === - - -async def test_01_basic_100ms(): - """Test 1: Basic default configuration (100ms).""" - connection_params = AssemblyAIConnectionParams( - speech_model="u3-rt-pro", - min_turn_silence=100, - ) - await run_bot(connection_params, "Basic Default Configuration (100ms)") - - -async def test_02_custom_200ms(): - """Test 2: Custom min_end_of_turn_silence (200ms).""" - connection_params = AssemblyAIConnectionParams( - speech_model="u3-rt-pro", - min_turn_silence=200, - ) - await run_bot(connection_params, "Custom Turn Silence (200ms)") - - -async def test_03_custom_500ms(): - """Test 3: Longer silence threshold (500ms).""" - connection_params = AssemblyAIConnectionParams( - speech_model="u3-rt-pro", - min_turn_silence=500, - ) - await run_bot(connection_params, "Longer Turn Silence (500ms)") - - -# === PROMPTING & WARNINGS (4-7) === - - -async def test_04_max_warning(): - """Test 4: max_turn_silence warning (should be overridden).""" - logger.warning("⚠️ EXPECT WARNING: max_turn_silence will be overridden") - connection_params = AssemblyAIConnectionParams( - speech_model="u3-rt-pro", - max_turn_silence=500, - ) - await run_bot(connection_params, "max_turn_silence Override Warning") - - -async def test_05_prompt_warning(): - """Test 5: Custom prompt warning.""" - logger.warning("⚠️ EXPECT WARNING: Custom prompts should be tested carefully") - connection_params = AssemblyAIConnectionParams( - speech_model="u3-rt-pro", - prompt="Transcribe speech accurately with proper punctuation.", - ) - await run_bot(connection_params, "Custom Prompt Warning Test") - - -async def test_06_prompt_keyterms_conflict(): - """Test 6: Prompt + keyterms conflict (should error).""" - logger.error("❌ EXPECT ERROR: Cannot use both prompt and keyterms_prompt") - try: - connection_params = AssemblyAIConnectionParams( - speech_model="u3-rt-pro", - prompt="Custom prompt", - keyterms_prompt=["test"], - ) - await run_bot(connection_params, "Prompt + Keyterms Conflict (ERROR)") - except ValueError as e: - logger.error(f"✅ EXPECTED ERROR: {e}") - input("\nPress Enter to continue...") - return - - -async def test_07_keyterms_difficult(): - """Test 7: Keyterms with difficult/unusual names.""" - # Use names that STT wouldn't normally get right - keyterms = ["Xiomara", "Saoirse", "Krzystof", "Nguyen", "Pipecat", "AssemblyAI"] - connection_params = AssemblyAIConnectionParams( - speech_model="u3-rt-pro", - keyterms_prompt=keyterms, - ) - logger.info("🎯 Boosted terms: Xiomara, Saoirse, Krzystof, Nguyen, Pipecat, AssemblyAI") - logger.info(" Try saying these difficult names to test boosting!") - await run_bot(connection_params, "Keyterms with Difficult Names") - - -# === DIARIZATION (8-9) === - - -async def test_08_diarization_basic(): - """Test 8: Basic diarization (speaker IDs logged).""" - connection_params = AssemblyAIConnectionParams( - speech_model="u3-rt-pro", - speaker_labels=True, - ) - logger.info("🎤 Diarization enabled - speaker IDs will be logged") - logger.info(" Try having multiple people speak!") - await run_bot(connection_params, "Diarization - Basic") - - -async def test_09_diarization_xml(): - """Test 9: Diarization with XML formatting.""" - connection_params = AssemblyAIConnectionParams( - speech_model="u3-rt-pro", - speaker_labels=True, - ) - logger.info("🎤 Diarization with XML tags") - logger.info(" Transcripts will include text") - await run_bot( - connection_params, - "Diarization - XML Formatting", - speaker_format="{text}", - ) - - -# === DYNAMIC UPDATES - SINGLE PARAMETER (10-13) === - - -async def test_10_dynamic_keyterms(): - """Test 10: Dynamic keyterms update with difficult names.""" - connection_params = AssemblyAIConnectionParams( - speech_model="u3-rt-pro", - ) - - async def dynamic_update(task): - logger.info("\n" + "=" * 80) - logger.info("PHASE 1: No keyterms boosting") - logger.info(" Try saying: Xiomara, Saoirse, Krzystof") - logger.info(" (May not transcribe correctly)") - logger.info("=" * 80) - await asyncio.sleep(15) - - logger.info("\n" + "=" * 80) - logger.info("🔄 UPDATING: Adding keyterms boost") - logger.info("=" * 80) - await task.queue_frame( - STTUpdateSettingsFrame( - delta=AssemblyAISTTSettings( - connection_params=AssemblyAIConnectionParams( - keyterms_prompt=["Xiomara", "Saoirse", "Krzystof", "Nguyen"] - ) - ) - ) - ) - logger.info("\n" + "=" * 80) - logger.info("PHASE 2: Keyterms NOW boosted") - logger.info(" Say the same names again: Xiomara, Saoirse, Krzystof") - logger.info(" (Should transcribe better now!)") - logger.info("=" * 80) - - logger.info("🔄 This test has 2 phases:") - logger.info(" Phase 1 (15s): No boosting - names may be wrong") - logger.info(" Phase 2: Keyterms added - names should improve") - await run_bot( - connection_params, - "Dynamic Keyterms Update (Before/After)", - test_dynamic_updates=dynamic_update, - ) - - -async def test_11_dynamic_silence(): - """Test 11: Dynamic silence parameter update (dramatic change).""" - connection_params = AssemblyAIConnectionParams( - speech_model="u3-rt-pro", - min_turn_silence=100, - ) - - async def dynamic_update(task): - logger.info("\n" + "=" * 80) - logger.info("PHASE 1: Quick responses (100ms silence threshold)") - logger.info(" Speak normally - bot responds quickly") - logger.info("=" * 80) - await asyncio.sleep(10) - - logger.info("\n" + "=" * 80) - logger.info("🔄 UPDATING: Changing silence from 100ms → 3000ms (3 seconds!)") - logger.info("=" * 80) - await task.queue_frame( - STTUpdateSettingsFrame( - delta=AssemblyAISTTSettings( - connection_params=AssemblyAIConnectionParams(min_turn_silence=3000) - ) - ) - ) - logger.info("\n" + "=" * 80) - logger.info("PHASE 2: Patient responses (3 second silence threshold)") - logger.info(" Bot will wait 3 full seconds before responding") - logger.info(" Try pausing mid-sentence - bot should NOT interrupt") - logger.info("=" * 80) - - logger.info("🔄 Dramatic change: 100ms → 3000ms after 10 seconds") - await run_bot( - connection_params, - "Dynamic Silence Update (100ms → 3s)", - test_dynamic_updates=dynamic_update, - ) - - -async def test_12_dynamic_prompt(): - """Test 12: Dynamic prompt update with keyterms in prompt.""" - connection_params = AssemblyAIConnectionParams( - speech_model="u3-rt-pro", - ) - - async def dynamic_update(task): - logger.info("\n" + "=" * 80) - logger.info("PHASE 1: Default prompt (no keyterms)") - logger.info(" Try saying: Xiomara, Saoirse, Krzystof") - logger.info(" (May not transcribe correctly)") - logger.info("=" * 80) - await asyncio.sleep(15) - - logger.info("\n" + "=" * 80) - logger.info("🔄 UPDATING: Adding custom prompt with keyterms") - logger.info("=" * 80) - custom_prompt = """Transcribe verbatim. Rules: -1) Always include punctuation in output. -2) Use period/question mark ONLY for complete sentences. -3) Use comma for mid-sentence pauses. -4) Use no punctuation for incomplete trailing speech. -5) Filler words (um, uh, so, like) indicate speaker will continue. - -Pay special attention to these names and transcribe them exactly: Xiomara, Saoirse, Krzystof, Nguyen.""" - await task.queue_frame( - STTUpdateSettingsFrame( - delta=AssemblyAISTTSettings( - connection_params=AssemblyAIConnectionParams(prompt=custom_prompt) - ) - ) - ) - logger.info("\n" + "=" * 80) - logger.info("PHASE 2: Prompt with keyterms NOW active") - logger.info(" Say the same names again: Xiomara, Saoirse, Krzystof") - logger.info(" (Should transcribe better now!)") - logger.info("=" * 80) - - logger.info("🔄 This test has 2 phases:") - logger.info(" Phase 1 (15s): Default prompt - names may be wrong") - logger.info(" Phase 2: Custom prompt with keyterms - names should improve") - await run_bot( - connection_params, - "Dynamic Prompt Update (with keyterms)", - test_dynamic_updates=dynamic_update, - ) - - -async def test_13_dynamic_clear_keyterms(): - """Test 13: Clear keyterms dynamically.""" - connection_params = AssemblyAIConnectionParams( - speech_model="u3-rt-pro", - keyterms_prompt=["Pipecat", "AssemblyAI"], - ) - - async def dynamic_update(task): - await asyncio.sleep(10) - logger.info("🔄 UPDATING: Clearing keyterms (empty array)") - await task.queue_frame( - STTUpdateSettingsFrame( - delta=AssemblyAISTTSettings( - connection_params=AssemblyAIConnectionParams(keyterms_prompt=[]) - ) - ) - ) - - logger.info("🎯 Initial: Pipecat, AssemblyAI boosted") - logger.info("🔄 After 10s: Keyterms will be cleared") - await run_bot( - connection_params, - "Dynamic Clear Keyterms", - test_dynamic_updates=dynamic_update, - ) - - -# === DYNAMIC UPDATES - MULTIPLE PARAMETERS (14-15) === - - -async def test_14_multi_param_update(): - """Test 14: Update multiple parameters at once.""" - connection_params = AssemblyAIConnectionParams( - speech_model="u3-rt-pro", - min_turn_silence=100, - ) - - async def dynamic_update(task): - await asyncio.sleep(10) - logger.info("🔄 UPDATING MULTIPLE: keyterms + silence") - await task.queue_frame( - STTUpdateSettingsFrame( - delta=AssemblyAISTTSettings( - connection_params=AssemblyAIConnectionParams( - keyterms_prompt=["Xiomara", "Pipecat"], - min_turn_silence=250, - ) - ) - ) - ) - - logger.info("🔄 After 10s: Will update BOTH keyterms AND silence threshold") - await run_bot( - connection_params, - "Multiple Parameter Update", - test_dynamic_updates=dynamic_update, - ) - - -async def test_15_complex_sequence(): - """Test 15: Complex multi-stage update sequence.""" - connection_params = AssemblyAIConnectionParams( - speech_model="u3-rt-pro", - ) - - async def dynamic_update(task): - logger.info("Stage 1: Initial (10s)") - await asyncio.sleep(10) - - logger.info("🔄 Stage 2: Add keyterms") - await task.queue_frame( - STTUpdateSettingsFrame( - delta=AssemblyAISTTSettings( - connection_params=AssemblyAIConnectionParams(keyterms_prompt=["Pipecat"]) - ) - ) - ) - await asyncio.sleep(10) - - logger.info("🔄 Stage 3: Change silence") - await task.queue_frame( - STTUpdateSettingsFrame( - delta=AssemblyAISTTSettings( - connection_params=AssemblyAIConnectionParams(min_turn_silence=200) - ) - ) - ) - await asyncio.sleep(10) - - logger.info("🔄 Stage 4: Update both") - await task.queue_frame( - STTUpdateSettingsFrame( - delta=AssemblyAISTTSettings( - connection_params=AssemblyAIConnectionParams( - keyterms_prompt=["AssemblyAI", "OpenAI"], - min_turn_silence=150, - ) - ) - ) - ) - - logger.info("🔄 Multi-stage: 4 configuration changes over 30 seconds") - await run_bot( - connection_params, - "Complex Update Sequence (4 stages)", - test_dynamic_updates=dynamic_update, - ) - - -# === MODE COMPARISON (16-17) === - - -async def test_16_pipecat_mode(): - """Test 16: Pipecat mode (VAD + Smart Turn controls turns).""" - connection_params = AssemblyAIConnectionParams( - speech_model="u3-rt-pro", - min_turn_silence=100, - ) - logger.info("🎯 Pipecat Mode: VAD + Smart Turn control turn detection") - logger.info(" Your min_end_of_turn_silence is sent but ForceEndpoint overrides it") - await run_bot( - connection_params, - "Pipecat Mode (VAD + Smart Turn)", - vad_force_turn_endpoint=True, - ) - - -async def test_17_stt_mode(): - """Test 17: STT mode (model controls turns).""" - connection_params = AssemblyAIConnectionParams( - speech_model="u3-rt-pro", - min_turn_silence=100, - ) - logger.info("🎯 STT Mode: u3-rt-pro model controls turn detection") - logger.info(" No ForceEndpoint - parameters are respected") - await run_bot( - connection_params, - "STT Mode (Model Turn Detection)", - vad_force_turn_endpoint=False, - ) - - -# === STT MODE TIMING EXPERIMENTS (18-20) === - - -async def test_18_stt_long_max_short_min(): - """Test 18: STT mode - Long max_turn_silence + Short min (5000ms + 100ms).""" - connection_params = AssemblyAIConnectionParams( - speech_model="u3-rt-pro", - min_turn_silence=100, # Short - quick confident turns - max_turn_silence=5000, # Long - allows pauses up to 5 seconds - ) - logger.info("🎯 STT Mode: Testing max/min parameter interaction") - logger.info(" min_turn_silence: 100ms (quick when confident)") - logger.info(" max_turn_silence: 5000ms (allows up to 5 second pauses)") - logger.info(" Try: Quick sentences (should respond fast) + Long pauses mid-thought") - await run_bot( - connection_params, - "STT: Long Max (5s) + Short Min (100ms)", - vad_force_turn_endpoint=False, - ) - - -async def test_19_stt_long_min(): - """Test 19: STT mode - Long min_turn_silence (3000ms).""" - connection_params = AssemblyAIConnectionParams( - speech_model="u3-rt-pro", - min_turn_silence=3000, # 3 seconds - max_turn_silence=5000, # 5 seconds - ) - logger.info("🎯 STT Mode: Testing long minimum silence requirement") - logger.info(" min_turn_silence: 3000ms") - logger.info(" max_turn_silence: 5000ms") - logger.info(" Bot will wait 3 full seconds of silence before responding!") - logger.info(" Try: Speaking with short pauses - bot should NOT interrupt") - await run_bot( - connection_params, - "STT: Long Min (3s)", - vad_force_turn_endpoint=False, - ) - - -async def test_20_stt_both_short(): - """Test 20: STT mode - Both short (max=300ms, min=100ms).""" - connection_params = AssemblyAIConnectionParams( - speech_model="u3-rt-pro", - min_turn_silence=100, # 100ms - max_turn_silence=300, # 300ms - ) - logger.info("🎯 STT Mode: Testing aggressive/quick response timing") - logger.info(" min_turn_silence: 100ms") - logger.info(" max_turn_silence: 300ms") - logger.info(" Bot will respond VERY quickly to any pause!") - logger.info(" Try: Speaking with natural pauses - expect quick responses") - await run_bot( - connection_params, - "STT: Both Short (300ms/100ms)", - vad_force_turn_endpoint=False, - ) - - -# === EDGE CASES (21-23) === - - -async def test_21_very_long_silence(): - """Test 21: Very long silence threshold (STT mode only).""" - connection_params = AssemblyAIConnectionParams( - speech_model="u3-rt-pro", - min_turn_silence=10000, # 10 seconds - ) - logger.warning("⚠️ STT Mode with 10 second silence threshold") - logger.info(" Bot will wait 10 seconds of silence before responding!") - await run_bot( - connection_params, - "Very Long Silence (10s) - STT Mode", - vad_force_turn_endpoint=False, - ) - - -async def test_22_very_short_silence(): - """Test 22: Very short silence threshold (50ms).""" - connection_params = AssemblyAIConnectionParams( - speech_model="u3-rt-pro", - min_turn_silence=50, - ) - logger.info("⚡ Very short silence threshold (50ms)") - logger.info(" Bot will respond very quickly!") - await run_bot(connection_params, "Very Short Silence (50ms)") - - -async def test_23_keyterms_plus_diarization(): - """Test 23: Keyterms + Diarization combined.""" - connection_params = AssemblyAIConnectionParams( - speech_model="u3-rt-pro", - keyterms_prompt=["Xiomara", "Saoirse", "Pipecat"], - speaker_labels=True, - ) - logger.info("🎯 Keyterms + 🎤 Diarization both enabled") - logger.info(" Try multiple speakers saying difficult names!") - await run_bot( - connection_params, - "Keyterms + Diarization Combined", - speaker_format="[{speaker}] {text}", - ) - - -# ============================================================================ -# Interactive Menu -# ============================================================================ - - -def show_menu(): - """Display the comprehensive test menu.""" - print("\n" + "=" * 80) - print("AssemblyAI u3-rt-pro Comprehensive Test Suite") - print("=" * 80) - print("\n📋 BASIC CONFIGURATION (1-3)") - print(" 1. Basic Default (100ms)") - print(" 2. Custom Silence (200ms)") - print(" 3. Longer Silence (500ms)") - - print("\n⚠️ PROMPTING & WARNINGS (4-7)") - print(" 4. max_turn_silence Warning") - print(" 5. Custom Prompt Warning") - print(" 6. Prompt + Keyterms Conflict (ERROR)") - print(" 7. Keyterms with Difficult Names") - - print("\n🎤 DIARIZATION (8-9)") - print(" 8. Diarization - Basic") - print(" 9. Diarization - XML Formatting") - - print("\n🔄 DYNAMIC UPDATES - SINGLE (10-13)") - print(" 10. Dynamic Keyterms (Before/After with difficult names)") - print(" 11. Dynamic Silence (100ms → 3s DRAMATIC)") - print(" 12. Dynamic Prompt with Keyterms (Before/After)") - print(" 13. Dynamic Clear Keyterms") - - print("\n🔄 DYNAMIC UPDATES - MULTIPLE (14-15)") - print(" 14. Multiple Parameters at Once") - print(" 15. Complex Update Sequence (4 stages)") - - print("\n⚖️ MODE COMPARISON (16-17)") - print(" 16. Pipecat Mode (VAD + Smart Turn)") - print(" 17. STT Mode (Model Turn Detection)") - - print("\n⏱️ STT MODE TIMING EXPERIMENTS (18-20)") - print(" 18. STT: Long Max (5s) + Short Min (100ms)") - print(" 19. STT: Long Min (3s)") - print(" 20. STT: Both Short (300ms/100ms)") - - print("\n🎯 EDGE CASES (21-23)") - print(" 21. Very Long Silence (10s - STT Mode)") - print(" 22. Very Short Silence (50ms)") - print(" 23. Keyterms + Diarization Combined") - - print("\n 0. Exit") - print("\n" + "=" * 80) - - -async def main(): - """Main interactive menu.""" - tests = { - "1": test_01_basic_100ms, - "2": test_02_custom_200ms, - "3": test_03_custom_500ms, - "4": test_04_max_warning, - "5": test_05_prompt_warning, - "6": test_06_prompt_keyterms_conflict, - "7": test_07_keyterms_difficult, - "8": test_08_diarization_basic, - "9": test_09_diarization_xml, - "10": test_10_dynamic_keyterms, - "11": test_11_dynamic_silence, - "12": test_12_dynamic_prompt, - "13": test_13_dynamic_clear_keyterms, - "14": test_14_multi_param_update, - "15": test_15_complex_sequence, - "16": test_16_pipecat_mode, - "17": test_17_stt_mode, - "18": test_18_stt_long_max_short_min, - "19": test_19_stt_long_min, - "20": test_20_stt_both_short, - "21": test_21_very_long_silence, - "22": test_22_very_short_silence, - "23": test_23_keyterms_plus_diarization, - } - - while True: - show_menu() - choice = input("Enter test number (or 0 to exit): ").strip() - - if choice == "0": - print("\n👋 Goodbye!") - break - - if choice in tests: - try: - await tests[choice]() - except KeyboardInterrupt: - print("\n\n⚠️ Test interrupted by user") - except Exception as e: - logger.error(f"Test failed with error: {e}") - import traceback - - traceback.print_exc() - - input("\n\nPress Enter to return to menu...") - else: - print(f"\n❌ Invalid choice: {choice}") - input("Press Enter to continue...") - - -if __name__ == "__main__": - try: - asyncio.run(main()) - except KeyboardInterrupt: - print("\n\n👋 Goodbye!") diff --git a/test_assemblyai_u3pro.py b/test_assemblyai_u3pro.py deleted file mode 100644 index 236ab9b50..000000000 --- a/test_assemblyai_u3pro.py +++ /dev/null @@ -1,582 +0,0 @@ -#!/usr/bin/env python3 -"""AssemblyAI u3-rt-pro Comprehensive Test Script - -Tests all features: -- Basic configuration -- Prompting and keyterms -- Diarization -- Dynamic updates -- Turn detection modes - -Usage: - python test_assemblyai_u3pro.py --test - python test_assemblyai_u3pro.py --interactive -""" - -import argparse -import asyncio -import os -import sys -from typing import List - -from dotenv import load_dotenv -from loguru import logger - -# Add src to path -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src")) - -from pipecat.audio.vad.silero import SileroVADAnalyzer -from pipecat.frames.frames import ( - EndFrame, - Frame, - LLMRunFrame, - STTUpdateSettingsFrame, - TranscriptionFrame, -) -from pipecat.pipeline.pipeline import Pipeline -from pipecat.pipeline.runner import PipelineRunner -from pipecat.pipeline.task import PipelineTask -from pipecat.processors.aggregators.llm_context import LLMContext -from pipecat.processors.aggregators.llm_response_universal import ( - LLMContextAggregatorPair, - LLMUserAggregatorParams, -) -from pipecat.processors.frame_processor import FrameDirection, FrameProcessor -from pipecat.services.assemblyai.models import AssemblyAIConnectionParams -from pipecat.services.assemblyai.stt import AssemblyAISTTService -from pipecat.services.cartesia.tts import CartesiaTTSService -from pipecat.services.openai.llm import OpenAILLMService -from pipecat.transports.local.audio import LocalAudioTransport, LocalAudioTransportParams - -load_dotenv() - - -# Test configuration -class TestConfig: - """Centralized test configuration.""" - - ASSEMBLYAI_API_KEY = os.getenv("ASSEMBLYAI_API_KEY") - OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") - CARTESIA_API_KEY = os.getenv("CARTESIA_API_KEY") - - @classmethod - def validate(cls): - """Validate all required API keys are set.""" - missing = [] - if not cls.ASSEMBLYAI_API_KEY: - missing.append("ASSEMBLYAI_API_KEY") - if not cls.OPENAI_API_KEY: - missing.append("OPENAI_API_KEY") - if not cls.CARTESIA_API_KEY: - missing.append("CARTESIA_API_KEY") - - if missing: - logger.error(f"Missing required environment variables: {', '.join(missing)}") - return False - return True - - -class TranscriptionLogger(FrameProcessor): - """Log transcriptions for test verification.""" - - async def process_frame(self, frame: Frame, direction: FrameDirection): - if isinstance(frame, TranscriptionFrame): - logger.info(f"📝 TRANSCRIPTION: {frame.text}") - logger.info(f" Speaker: {frame.user_id}") - logger.info(f" Finalized: {frame.finalized}") - if hasattr(frame, "result") and frame.result: - if hasattr(frame.result, "speaker"): - logger.info(f" Diarization: {frame.result.speaker}") - - await self.push_frame(frame, direction) - - -async def create_basic_voice_agent( - connection_params: AssemblyAIConnectionParams, - vad_force_turn_endpoint: bool = True, - speaker_format: str = None, -) -> tuple[PipelineTask, LocalAudioTransport]: - """Create a basic voice agent for testing. - - Args: - connection_params: AssemblyAI connection parameters - vad_force_turn_endpoint: Turn detection mode - speaker_format: Optional speaker formatting string - - Returns: - Tuple of (PipelineTask, LocalAudioTransport) - """ - # Create local audio transport (uses your microphone and speakers) - transport = LocalAudioTransport( - params=LocalAudioTransportParams( - audio_in_enabled=True, - audio_out_enabled=True, - ) - ) - - # Create STT - stt = AssemblyAISTTService( - api_key=TestConfig.ASSEMBLYAI_API_KEY, - connection_params=connection_params, - vad_force_turn_endpoint=vad_force_turn_endpoint, - speaker_format=speaker_format, - ) - - # Create TTS - tts = CartesiaTTSService( - api_key=TestConfig.CARTESIA_API_KEY, - voice_id="a0e99841-438c-4a64-b679-ae501e7d6091", # Conversational English - ) - - # Create LLM context and service - messages = [ - { - "role": "system", - "content": ( - "You are a helpful voice assistant. Keep responses brief and natural. " - "If you see speaker tags like text, acknowledge " - "that you understand multiple speakers are present." - ), - } - ] - - context = LLMContext(messages) - llm = OpenAILLMService(api_key=TestConfig.OPENAI_API_KEY, model="gpt-4") - - # Create aggregators with VAD - user_aggregator, assistant_aggregator = LLMContextAggregatorPair( - context, - user_params=LLMUserAggregatorParams( - vad_analyzer=SileroVADAnalyzer(), - ), - ) - - # Create transcription logger - transcription_logger = TranscriptionLogger() - - # Create pipeline - pipeline = Pipeline( - [ - transport.input(), - stt, - transcription_logger, - user_aggregator, - llm, - tts, - transport.output(), - assistant_aggregator, - ] - ) - - # Create task - task = PipelineTask(pipeline) - - return task, transport - - -# ============================================================================ -# Test Functions -# ============================================================================ - - -async def test_basic_config(): - """Test 1: Basic default configuration.""" - logger.info("=" * 80) - logger.info("TEST 1: Basic Default Configuration") - logger.info("=" * 80) - - connection_params = AssemblyAIConnectionParams(speech_model="u3-rt-pro") - - task, transport = await create_basic_voice_agent(connection_params) - - logger.info("✅ Service created successfully with default params") - logger.info("Expected: min=max=100ms, u3-rt-pro model") - logger.info("Speak into your microphone to test transcription") - - # Trigger initial bot greeting - await task.queue_frames([LLMRunFrame()]) - - runner = PipelineRunner() - await runner.run(task) - - -async def test_custom_min_silence(): - """Test 2: Custom min_turn_silence.""" - logger.info("=" * 80) - logger.info("TEST 2: Custom min_turn_silence") - logger.info("=" * 80) - - connection_params = AssemblyAIConnectionParams(speech_model="u3-rt-pro", min_turn_silence=200) - - task, transport = await create_basic_voice_agent(connection_params) - - logger.info("✅ Service created with min=200ms") - logger.info("Expected: Both min and max set to 200ms") - logger.info("Speak short phrases and observe turn detection timing") - - runner = PipelineRunner() - await runner.run(task) - - -async def test_max_silence_warning(): - """Test 3: Setting max_turn_silence should trigger warning.""" - logger.info("=" * 80) - logger.info("TEST 3: max_turn_silence Warning") - logger.info("=" * 80) - - connection_params = AssemblyAIConnectionParams( - speech_model="u3-rt-pro", - min_turn_silence=100, - max_turn_silence=500, # Should trigger warning - ) - - task, transport = await create_basic_voice_agent(connection_params) - - logger.info("⚠️ Check logs above for warning about max_turn_silence being overridden") - logger.info("Expected: Warning logged, max set to 100ms (same as min)") - - runner = PipelineRunner() - await runner.run(task) - - -async def test_custom_prompt_warning(): - """Test 5: Custom prompt should trigger warning.""" - logger.info("=" * 80) - logger.info("TEST 5: Custom Prompt Warning") - logger.info("=" * 80) - - connection_params = AssemblyAIConnectionParams( - speech_model="u3-rt-pro", - prompt="Transcribe verbatim. Always include punctuation.", - ) - - task, transport = await create_basic_voice_agent(connection_params) - - logger.info("⚠️ Check logs above for warning about testing without prompt first") - logger.info("Expected: Warning logged, service continues with custom prompt") - - runner = PipelineRunner() - await runner.run(task) - - -async def test_prompt_keyterms_conflict(): - """Test 6: Prompt + keyterms_prompt should raise error.""" - logger.info("=" * 80) - logger.info("TEST 6: Prompt + Keyterms Conflict (Error)") - logger.info("=" * 80) - - try: - connection_params = AssemblyAIConnectionParams( - speech_model="u3-rt-pro", - prompt="Custom prompt", - keyterms_prompt=["test", "words"], - ) - - task, transport = await create_basic_voice_agent(connection_params) - logger.error("❌ TEST FAILED: Should have raised ValueError") - except ValueError as e: - logger.info(f"✅ TEST PASSED: ValueError raised as expected") - logger.info(f" Error message: {e}") - - -async def test_keyterms_basic(): - """Test 7: Basic keyterms at initialization.""" - logger.info("=" * 80) - logger.info("TEST 7: Basic Keyterms Prompting") - logger.info("=" * 80) - - connection_params = AssemblyAIConnectionParams( - speech_model="u3-rt-pro", - keyterms_prompt=["Pipecat", "AssemblyAI", "Universal-3", "streaming"], - ) - - task, transport = await create_basic_voice_agent(connection_params) - - logger.info("✅ Service created with keyterms: Pipecat, AssemblyAI, Universal-3, streaming") - logger.info("Expected: Boosted recognition for these terms") - logger.info("Try saying: 'I'm testing Pipecat with AssemblyAI Universal-3 for streaming'") - - runner = PipelineRunner() - await runner.run(task) - - -async def test_diarization_no_format(): - """Test 10: Diarization enabled without formatting.""" - logger.info("=" * 80) - logger.info("TEST 10: Diarization Enabled (No Formatting)") - logger.info("=" * 80) - - connection_params = AssemblyAIConnectionParams(speech_model="u3-rt-pro", speaker_labels=True) - - task, transport = await create_basic_voice_agent(connection_params) - - logger.info("✅ Service created with speaker_labels=True") - logger.info("Expected: Speaker IDs in user_id field, plain text in transcript") - logger.info("Have multiple people speak to see different speaker labels") - - runner = PipelineRunner() - await runner.run(task) - - -async def test_diarization_xml_format(): - """Test 11: Diarization with XML formatting.""" - logger.info("=" * 80) - logger.info("TEST 11: Diarization with XML Formatting") - logger.info("=" * 80) - - connection_params = AssemblyAIConnectionParams(speech_model="u3-rt-pro", speaker_labels=True) - - task, transport = await create_basic_voice_agent( - connection_params, speaker_format="<{speaker}>{text}" - ) - - logger.info("✅ Service created with XML speaker formatting") - logger.info("Expected: Text like 'Hello'") - logger.info("Have multiple people speak to see formatted speaker tags") - - runner = PipelineRunner() - await runner.run(task) - - -async def test_dynamic_keyterms(): - """Test 13: Dynamic keyterms updates.""" - logger.info("=" * 80) - logger.info("TEST 13: Dynamic Keyterms Updates") - logger.info("=" * 80) - - connection_params = AssemblyAIConnectionParams(speech_model="u3-rt-pro") - - task, transport = await create_basic_voice_agent(connection_params) - - async def update_keyterms_stages(): - """Simulate multi-stage conversation with keyterms updates.""" - await asyncio.sleep(5) # Wait for connection - - # Stage 1: Greeting - logger.info("🔄 STAGE 1: Greeting (general terms)") - update1 = STTUpdateSettingsFrame( - settings={"keyterms_prompt": ["hello", "hi", "good morning", "welcome"]} - ) - await task.queue_frames([update1]) - - await asyncio.sleep(10) - - # Stage 2: Name collection - logger.info("🔄 STAGE 2: Name Collection") - update2 = STTUpdateSettingsFrame( - settings={ - "keyterms_prompt": [ - "first name", - "last name", - "John", - "Jane", - "Smith", - "Johnson", - ] - } - ) - await task.queue_frames([update2]) - - await asyncio.sleep(10) - - # Stage 3: Medical info - logger.info("🔄 STAGE 3: Medical Information") - update3 = STTUpdateSettingsFrame( - settings={ - "keyterms_prompt": [ - "cardiology", - "echocardiogram", - "blood pressure", - "Dr. Smith", - "metoprolol", - ] - } - ) - await task.queue_frames([update3]) - - await asyncio.sleep(10) - - # Stage 4: Clear keyterms - logger.info("🔄 STAGE 4: Clear Keyterms") - update4 = STTUpdateSettingsFrame(settings={"keyterms_prompt": []}) - await task.queue_frames([update4]) - - # Start update task - asyncio.create_task(update_keyterms_stages()) - - logger.info("✅ Service created, will update keyterms every 10 seconds") - logger.info("Expected: Different keyterms at each stage") - logger.info("Watch logs for 'STAGE X' messages and test relevant terms") - - runner = PipelineRunner() - await runner.run(task) - - -async def test_dynamic_silence_params(): - """Test 15: Dynamic silence parameter updates.""" - logger.info("=" * 80) - logger.info("TEST 15: Dynamic Silence Parameters") - logger.info("=" * 80) - - connection_params = AssemblyAIConnectionParams(speech_model="u3-rt-pro") - - task, transport = await create_basic_voice_agent(connection_params) - - async def update_silence_params(): - """Update silence parameters for different scenarios.""" - await asyncio.sleep(5) - - # Normal conversation - logger.info("🔄 PHASE 1: Normal conversation (default timing)") - await asyncio.sleep(10) - - # Reading credit card - logger.info("🔄 PHASE 2: Reading numbers (longer silence tolerance)") - update1 = STTUpdateSettingsFrame( - settings={ - "max_turn_silence": 5000, - "min_turn_silence": 300, - } - ) - await task.queue_frames([update1]) - - await asyncio.sleep(15) - - # Back to normal - logger.info("🔄 PHASE 3: Back to normal conversation") - update2 = STTUpdateSettingsFrame( - settings={ - "max_turn_silence": 1200, - "min_turn_silence": 100, - } - ) - await task.queue_frames([update2]) - - asyncio.create_task(update_silence_params()) - - logger.info("✅ Service will update silence parameters during conversation") - logger.info("Expected: Longer pauses tolerated in Phase 2") - logger.info("Try pausing between words to test") - - runner = PipelineRunner() - await runner.run(task) - - -async def test_multi_param_update(): - """Test 17: Update multiple parameters at once.""" - logger.info("=" * 80) - logger.info("TEST 17: Multiple Parameter Update") - logger.info("=" * 80) - - connection_params = AssemblyAIConnectionParams(speech_model="u3-rt-pro") - - task, transport = await create_basic_voice_agent(connection_params) - - async def multi_update(): - await asyncio.sleep(5) - - logger.info("🔄 Updating multiple parameters together") - update = STTUpdateSettingsFrame( - settings={ - "keyterms_prompt": ["account", "routing", "number"], - "max_turn_silence": 3000, - "min_turn_silence": 200, - } - ) - await task.queue_frames([update]) - - logger.info("✅ Check logs for single UpdateConfiguration message") - - asyncio.create_task(multi_update()) - - logger.info("Expected: All params updated in single WebSocket message") - - runner = PipelineRunner() - await runner.run(task) - - -# ============================================================================ -# Main Test Runner -# ============================================================================ - - -def main(): - """Main test runner.""" - parser = argparse.ArgumentParser(description="Test AssemblyAI u3-rt-pro integration") - parser.add_argument( - "--test", - type=str, - default="basic", - help="Test to run (basic, custom_min, max_warning, prompt_warning, " - "prompt_keyterms_conflict, keyterms, diarization, diarization_xml, " - "dynamic_keyterms, dynamic_silence, multi_param, all)", - ) - parser.add_argument("--interactive", action="store_true", help="Run in interactive mode") - - args = parser.parse_args() - - # Validate environment - if not TestConfig.validate(): - logger.error("Please set all required environment variables in .env") - sys.exit(1) - - # Test mapping - tests = { - "basic": test_basic_config, - "custom_min": test_custom_min_silence, - "max_warning": test_max_silence_warning, - "prompt_warning": test_custom_prompt_warning, - "prompt_keyterms_conflict": test_prompt_keyterms_conflict, - "keyterms": test_keyterms_basic, - "diarization": test_diarization_no_format, - "diarization_xml": test_diarization_xml_format, - "dynamic_keyterms": test_dynamic_keyterms, - "dynamic_silence": test_dynamic_silence_params, - "multi_param": test_multi_param_update, - } - - if args.interactive: - logger.info("Interactive mode - select test to run:") - for i, (name, _) in enumerate(tests.items(), 1): - logger.info(f"{i}. {name}") - logger.info(f"{len(tests) + 1}. Run all tests") - - choice = input("\nEnter test number: ") - try: - choice_num = int(choice) - if choice_num == len(tests) + 1: - args.test = "all" - else: - args.test = list(tests.keys())[choice_num - 1] - except (ValueError, IndexError): - logger.error("Invalid choice") - sys.exit(1) - - # Run test(s) - if args.test == "all": - logger.info("Running all tests sequentially...") - for test_name, test_func in tests.items(): - try: - asyncio.run(test_func()) - except KeyboardInterrupt: - logger.info(f"Test '{test_name}' interrupted") - break - except Exception as e: - logger.error(f"Test '{test_name}' failed: {e}") - else: - if args.test not in tests: - logger.error(f"Unknown test: {args.test}") - logger.info(f"Available tests: {', '.join(tests.keys())}") - sys.exit(1) - - try: - asyncio.run(tests[args.test]()) - except KeyboardInterrupt: - logger.info("Test interrupted") - except Exception as e: - logger.error(f"Test failed: {e}") - raise - - -if __name__ == "__main__": - main() From 55a641e2584d14c2dfff6fe65561331b08229646 Mon Sep 17 00:00:00 2001 From: dhruvladia-sarvam Date: Sun, 1 Mar 2026 22:10:27 +0530 Subject: [PATCH 20/68] fix(sarvam): standardize STT/TTS User-Agent headers --- src/pipecat/services/sarvam/stt.py | 34 +++++++++++++++++------------- src/pipecat/services/sarvam/tts.py | 11 ++++++---- 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/src/pipecat/services/sarvam/stt.py b/src/pipecat/services/sarvam/stt.py index 379473c6f..f4ebf7574 100644 --- a/src/pipecat/services/sarvam/stt.py +++ b/src/pipecat/services/sarvam/stt.py @@ -266,15 +266,10 @@ class SarvamSTTService(STTService): # Initialize Sarvam SDK client self._sdk_headers = sdk_headers() - # NOTE: We avoid passing non-standard kwargs here because different sarvamai - # versions expose different constructor signatures (static type checkers - # complain otherwise). We instead inject headers best-effort below. - self._sarvam_client = AsyncSarvamAI(api_subscription_key=api_key) - for attr in ("default_headers", "_default_headers", "headers", "_headers"): - d = getattr(self._sarvam_client, attr, None) - if isinstance(d, dict): - d.update(self._sdk_headers) - break + # Pass Pipecat SDK headers directly at client construction time so they are + # merged by the Sarvam SDK's client wrapper and consistently applied to + # WebSocket handshake requests. + self._sarvam_client = AsyncSarvamAI(api_subscription_key=api_key, headers=self._sdk_headers) self._websocket_context = None self._socket_client = None self._receive_task = None @@ -517,20 +512,29 @@ class SarvamSTTService(STTService): connect_kwargs["prompt"] = self._settings.prompt def _connect_with_sdk_headers(connect_fn, **kwargs): - # Different SDK versions may use different kwarg names. # If prompt is unsupported at connect-time, retry without it. + # Headers are supplied through request_options because this is a + # documented SDK parameter that survives SDK signature changes. + request_options = {"additional_headers": self._sdk_headers} + logger.debug( + f"Sarvam STT connect request_options.additional_headers: " + f"{request_options['additional_headers']}" + ) attempts = [kwargs] if "prompt" in kwargs: attempts.append({k: v for k, v in kwargs.items() if k != "prompt"}) last_type_error = None for attempt_kwargs in attempts: - for header_kw in ("headers", "additional_headers", "extra_headers"): - try: - return connect_fn(**attempt_kwargs, **{header_kw: self._sdk_headers}) - except TypeError as e: - last_type_error = e try: + return connect_fn( + **attempt_kwargs, + request_options=request_options, + ) + except TypeError as e: + last_type_error = e + try: + # Fallback for SDK builds that don't expose request_options. return connect_fn(**attempt_kwargs) except TypeError as e: last_type_error = e diff --git a/src/pipecat/services/sarvam/tts.py b/src/pipecat/services/sarvam/tts.py index 87604a9f9..e92ade2e5 100644 --- a/src/pipecat/services/sarvam/tts.py +++ b/src/pipecat/services/sarvam/tts.py @@ -1013,12 +1013,15 @@ class SarvamTTSService(InterruptibleTTSService): if self._websocket and self._websocket.state is State.OPEN: return + ws_additional_headers = { + "api-subscription-key": self._api_key, + **sdk_headers(), + } + self._websocket = await websocket_connect( self._websocket_url, - additional_headers={ - "api-subscription-key": self._api_key, - **sdk_headers(), - }, + additional_headers=ws_additional_headers, + user_agent_header=None, ) logger.debug("Connected to Sarvam TTS Websocket") await self._send_config() From 1242f1c10ef0404a03346c0489eceac749548732 Mon Sep 17 00:00:00 2001 From: dhruvladia-sarvam Date: Mon, 2 Mar 2026 17:29:03 +0530 Subject: [PATCH 21/68] changelog entry --- changelog/3886.other.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/3886.other.md diff --git a/changelog/3886.other.md b/changelog/3886.other.md new file mode 100644 index 000000000..0e9fdafed --- /dev/null +++ b/changelog/3886.other.md @@ -0,0 +1 @@ +- Standardized Sarvam STT/TTS User-Agent header handling to consistently send Pipecat SDK identity in websocket requests. \ No newline at end of file From 018ead85514a4cab3de6519e9678ae83f448335b Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Fri, 27 Feb 2026 19:32:08 -0500 Subject: [PATCH 22/68] Changelog for PR 3873, docstrings change --- changelog/3873.added.md | 1 + src/pipecat/services/rime/tts.py | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 changelog/3873.added.md diff --git a/changelog/3873.added.md b/changelog/3873.added.md new file mode 100644 index 000000000..ed01b8e5d --- /dev/null +++ b/changelog/3873.added.md @@ -0,0 +1 @@ +- Added support for the `speed_alpha` parameter to the `arcana` model in `RimeTTSService`. diff --git a/src/pipecat/services/rime/tts.py b/src/pipecat/services/rime/tts.py index 2dbaf2760..944ff4e58 100644 --- a/src/pipecat/services/rime/tts.py +++ b/src/pipecat/services/rime/tts.py @@ -147,10 +147,10 @@ class RimeTTSService(AudioContextTTSService): Parameters: language: Language for synthesis. Defaults to English. segment: Text segmentation mode ("immediate", "bySentence", "never"). + speed_alpha: Speech speed multiplier. repetition_penalty: Token repetition penalty (arcana only). temperature: Sampling temperature (arcana only). top_p: Cumulative probability threshold (arcana only). - speed_alpha: Speech speed multiplier (mistv2 only). reduce_latency: Whether to reduce latency at potential quality cost (mistv2 only). pause_between_brackets: Whether to add pauses between bracketed content (mistv2 only). phonemize_between_brackets: Whether to phonemize bracketed content (mistv2 only). @@ -160,12 +160,12 @@ class RimeTTSService(AudioContextTTSService): language: Optional[Language] = Language.EN segment: Optional[str] = None + speed_alpha: Optional[float] = None # Arcana params repetition_penalty: Optional[float] = None temperature: Optional[float] = None top_p: Optional[float] = None # Mistv2 params - speed_alpha: Optional[float] = None reduce_latency: Optional[bool] = None pause_between_brackets: Optional[bool] = None phonemize_between_brackets: Optional[bool] = None @@ -230,12 +230,12 @@ class RimeTTSService(AudioContextTTSService): else None, segment=params.segment, inlineSpeedAlpha=None, # Not applicable here + speedAlpha=params.speed_alpha, # Arcana params repetition_penalty=params.repetition_penalty, temperature=params.temperature, top_p=params.top_p, # Mistv2 params - speedAlpha=params.speed_alpha, reduceLatency=params.reduce_latency, pauseBetweenBrackets=params.pause_between_brackets, phonemizeBetweenBrackets=params.phonemize_between_brackets, From 07ba2550733b21598f5846e392844e649c48f372 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Mon, 2 Mar 2026 09:20:24 -0500 Subject: [PATCH 23/68] Fix update-docs workflow OIDC failure with pull_request_target The switch from pull_request to pull_request_target (for fork PR secret access) broke claude-code-action default OIDC-based GitHub App authentication. Pass github_token explicitly to bypass OIDC. --- .github/workflows/update-docs.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/update-docs.yml b/.github/workflows/update-docs.yml index a9066762d..d26862766 100644 --- a/.github/workflows/update-docs.yml +++ b/.github/workflows/update-docs.yml @@ -59,6 +59,7 @@ jobs: DOCS_SYNC_TOKEN: ${{ secrets.DOCS_SYNC_TOKEN }} with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + github_token: ${{ secrets.GITHUB_TOKEN }} prompt: | You are updating documentation for the pipecat-ai/docs repository based on changes merged in PR #${{ steps.pr.outputs.number }} of pipecat-ai/pipecat. From f386722ef9dfd038ae1fe4100b7671086cd02023 Mon Sep 17 00:00:00 2001 From: dhruvladia-sarvam Date: Mon, 2 Mar 2026 20:38:39 +0530 Subject: [PATCH 24/68] removing unnecessary logs --- src/pipecat/services/sarvam/stt.py | 5 +---- src/pipecat/services/sarvam/tts.py | 1 - 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/pipecat/services/sarvam/stt.py b/src/pipecat/services/sarvam/stt.py index f4ebf7574..9e245aece 100644 --- a/src/pipecat/services/sarvam/stt.py +++ b/src/pipecat/services/sarvam/stt.py @@ -516,10 +516,7 @@ class SarvamSTTService(STTService): # Headers are supplied through request_options because this is a # documented SDK parameter that survives SDK signature changes. request_options = {"additional_headers": self._sdk_headers} - logger.debug( - f"Sarvam STT connect request_options.additional_headers: " - f"{request_options['additional_headers']}" - ) + attempts = [kwargs] if "prompt" in kwargs: attempts.append({k: v for k, v in kwargs.items() if k != "prompt"}) diff --git a/src/pipecat/services/sarvam/tts.py b/src/pipecat/services/sarvam/tts.py index e92ade2e5..c18933407 100644 --- a/src/pipecat/services/sarvam/tts.py +++ b/src/pipecat/services/sarvam/tts.py @@ -1021,7 +1021,6 @@ class SarvamTTSService(InterruptibleTTSService): self._websocket = await websocket_connect( self._websocket_url, additional_headers=ws_additional_headers, - user_agent_header=None, ) logger.debug("Connected to Sarvam TTS Websocket") await self._send_config() From c54232bdb40c28c7e28e1600bd3492212ff4b6cc Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Sat, 28 Feb 2026 14:04:21 -0500 Subject: [PATCH 25/68] Add StartupTimingObserver for measuring processor start() times Tracks how long each processor start method takes during pipeline startup by measuring StartFrame arrive/leave deltas. Emits a timing report via the on_startup_timing_report event and auto-logs a summary. Internal pipeline processors are excluded from reports by default. --- .../foundational/29-turn-tracking-observer.py | 12 +- .../observers/startup_timing_observer.py | 232 ++++++++++++++++++ tests/test_startup_timing_observer.py | 186 ++++++++++++++ 3 files changed, 427 insertions(+), 3 deletions(-) create mode 100644 src/pipecat/observers/startup_timing_observer.py create mode 100644 tests/test_startup_timing_observer.py diff --git a/examples/foundational/29-turn-tracking-observer.py b/examples/foundational/29-turn-tracking-observer.py index 321197db2..3e85ddfb8 100644 --- a/examples/foundational/29-turn-tracking-observer.py +++ b/examples/foundational/29-turn-tracking-observer.py @@ -12,6 +12,7 @@ from loguru import logger from pipecat.audio.vad.silero import SileroVADAnalyzer from pipecat.frames.frames import LLMRunFrame +from pipecat.observers.startup_timing_observer import StartupTimingObserver from pipecat.observers.user_bot_latency_observer import UserBotLatencyObserver from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -87,8 +88,8 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): ] ) - # Create latency tracking observer latency_observer = UserBotLatencyObserver() + startup_observer = StartupTimingObserver() task = PipelineTask( pipeline, @@ -97,14 +98,19 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): enable_usage_metrics=True, ), idle_timeout_secs=runner_args.pipeline_idle_timeout_secs, - observers=[latency_observer], + observers=[latency_observer, startup_observer], ) - # Log latency measurements using the event handler @latency_observer.event_handler("on_latency_measured") async def on_latency_measured(observer, latency_seconds): logger.info(f"⏱️ User-to-bot latency: {latency_seconds:.3f}s") + @startup_observer.event_handler("on_startup_timing_report") + async def on_startup_timing_report(observer, report): + logger.info(f"Total startup: {report.total_duration_secs:.3f}s") + for timing in report.processor_timings: + logger.info(f" {timing.processor_name}: {timing.duration_secs:.3f}s") + turn_observer = task.turn_tracking_observer if turn_observer: diff --git a/src/pipecat/observers/startup_timing_observer.py b/src/pipecat/observers/startup_timing_observer.py new file mode 100644 index 000000000..0f3ad0b7a --- /dev/null +++ b/src/pipecat/observers/startup_timing_observer.py @@ -0,0 +1,232 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Observer for tracking pipeline startup timing. + +This module provides an observer that measures how long each processor's +``start()`` method takes during pipeline startup. It works by tracking +when a ``StartFrame`` arrives at a processor (``on_process_frame``) versus +when it leaves (``on_push_frame``), giving the exact ``start()`` duration +for each processor in the pipeline. + +Example:: + + observer = StartupTimingObserver() + + @observer.event_handler("on_startup_timing_report") + async def on_report(observer, report): + for t in report.processor_timings: + print(f"{t.processor_name}: {t.duration_secs:.3f}s") + + task = PipelineTask(pipeline, observers=[observer]) +""" + +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Tuple, Type + +from loguru import logger + +from pipecat.frames.frames import StartFrame +from pipecat.observers.base_observer import BaseObserver, FrameProcessed, FramePushed +from pipecat.pipeline.base_pipeline import BasePipeline +from pipecat.pipeline.pipeline import PipelineSink, PipelineSource +from pipecat.processors.frame_processor import FrameDirection, FrameProcessor + +# Internal pipeline types excluded from tracking by default. +_INTERNAL_TYPES = (PipelineSink, PipelineSource, BasePipeline) + + +@dataclass +class ProcessorStartupTiming: + """Startup timing for a single processor. + + Parameters: + processor_name: The name of the processor. + duration_secs: How long the processor's start() took, in seconds. + """ + + processor_name: str + duration_secs: float + + +@dataclass +class StartupTimingReport: + """Report of startup timings for all measured processors. + + Parameters: + total_duration_secs: Total wall-clock time from first to last processor start. + processor_timings: Per-processor timing data, in pipeline order. + """ + + total_duration_secs: float + processor_timings: List[ProcessorStartupTiming] = field(default_factory=list) + + +class StartupTimingObserver(BaseObserver): + """Observer that measures processor startup times during pipeline initialization. + + Tracks how long each processor's ``start()`` method takes by measuring the + time between when a ``StartFrame`` arrives at a processor and when it is + pushed downstream. This captures WebSocket connections, API authentication, + model loading, and other initialization work. + + By default, internal pipeline processors (``PipelineSource``, ``PipelineSink``, + ``Pipeline``) are excluded from the report. Pass ``processor_types`` to + measure only specific types. + + Event handlers available: + + - on_startup_timing_report: Called once after startup completes with the full + timing report. + + Example:: + + observer = StartupTimingObserver( + processor_types=(STTService, TTSService) + ) + + @observer.event_handler("on_startup_timing_report") + async def on_report(observer, report): + for t in report.processor_timings: + logger.info(f"{t.processor_name}: {t.duration_secs:.3f}s") + + task = PipelineTask(pipeline, observers=[observer]) + + Args: + processor_types: Optional tuple of processor types to measure. If None, + all non-internal processors are measured. + """ + + def __init__( + self, + *, + processor_types: Optional[Tuple[Type[FrameProcessor], ...]] = None, + **kwargs, + ): + """Initialize the startup timing observer. + + Args: + processor_types: Optional tuple of processor types to measure. + If None, all non-internal processors are measured. + **kwargs: Additional arguments passed to parent class. + """ + super().__init__(**kwargs) + self._processor_types = processor_types + + # Map processor ID -> (processor, arrival_timestamp_ns) + self._arrivals: Dict[int, Tuple[FrameProcessor, int]] = {} + + # Collected timings in pipeline order. + self._timings: List[ProcessorStartupTiming] = [] + + # Lock onto the first StartFrame we see (by frame ID). + self._start_frame_id: Optional[str] = None + + # Whether we've already emitted the report. + self._reported = False + + self._register_event_handler("on_startup_timing_report") + + def _should_track(self, processor: FrameProcessor) -> bool: + """Check if a processor should be tracked for timing. + + Args: + processor: The processor to check. + + Returns: + True if the processor matches the filter or no filter is set. + """ + if self._processor_types is not None: + return isinstance(processor, self._processor_types) + # Default: exclude internal pipeline plumbing. + return not isinstance(processor, _INTERNAL_TYPES) + + async def on_process_frame(self, data: FrameProcessed): + """Record when a StartFrame arrives at a processor. + + When a ``StartFrame`` reaches a ``PipelineSink``, startup is complete + (the frame has traversed the entire pipeline) and the report is emitted. + + Args: + data: The frame processing event data. + """ + if self._reported: + return + + if not isinstance(data.frame, StartFrame): + return + + if data.direction != FrameDirection.DOWNSTREAM: + return + + # Lock onto the first StartFrame. + if self._start_frame_id is None: + self._start_frame_id = data.frame.id + elif data.frame.id != self._start_frame_id: + return + + # When the StartFrame reaches a PipelineSink, all processors have + # completed start(). PipelineSinks use direct mode so the outermost + # sink fires last within the same synchronous call chain. + if isinstance(data.processor, PipelineSink): + if self._timings: + await self._emit_report() + return + + if self._should_track(data.processor): + self._arrivals[data.processor.id] = (data.processor, data.timestamp) + + async def on_push_frame(self, data: FramePushed): + """Record when a StartFrame leaves a processor and compute the delta. + + Args: + data: The frame push event data. + """ + if self._reported: + return + + if not isinstance(data.frame, StartFrame): + return + + if data.direction != FrameDirection.DOWNSTREAM: + return + + if self._start_frame_id is not None and data.frame.id != self._start_frame_id: + return + + arrival = self._arrivals.pop(data.source.id, None) + if arrival is None: + return + + processor, arrival_ts = arrival + duration_ns = data.timestamp - arrival_ts + duration_secs = duration_ns / 1e9 + + self._timings.append( + ProcessorStartupTiming( + processor_name=processor.name, + duration_secs=duration_secs, + ) + ) + + async def _emit_report(self): + """Build and emit the startup timing report.""" + if self._reported: + return + self._reported = True + + total = sum(t.duration_secs for t in self._timings) + + report = StartupTimingReport( + total_duration_secs=total, + processor_timings=self._timings, + ) + + logger.debug(f"Pipeline startup completed in {total:.3f}s") + for t in self._timings: + logger.debug(f" {t.processor_name}: {t.duration_secs:.3f}s") + + await self._call_event_handler("on_startup_timing_report", report) diff --git a/tests/test_startup_timing_observer.py b/tests/test_startup_timing_observer.py new file mode 100644 index 000000000..e3cd7c2b7 --- /dev/null +++ b/tests/test_startup_timing_observer.py @@ -0,0 +1,186 @@ +import asyncio +import unittest + +from pipecat.frames.frames import Frame, StartFrame, TextFrame +from pipecat.observers.startup_timing_observer import ( + StartupTimingObserver, + StartupTimingReport, +) +from pipecat.processors.frame_processor import FrameDirection, FrameProcessor +from pipecat.tests.utils import run_test + + +class SlowStartProcessor(FrameProcessor): + """A processor that sleeps during start to simulate slow initialization.""" + + def __init__(self, delay: float = 0.1, **kwargs): + super().__init__(**kwargs) + self._delay = delay + + async def process_frame(self, frame: Frame, direction: FrameDirection): + await super().process_frame(frame, direction) + if isinstance(frame, StartFrame): + await asyncio.sleep(self._delay) + await self.push_frame(frame, direction) + + +class FastProcessor(FrameProcessor): + """A processor with no start delay.""" + + async def process_frame(self, frame: Frame, direction: FrameDirection): + await super().process_frame(frame, direction) + await self.push_frame(frame, direction) + + +class TestStartupTimingObserver(unittest.IsolatedAsyncioTestCase): + """Tests for StartupTimingObserver.""" + + async def test_timing_reported(self): + """Test that startup timing is measured and reported.""" + observer = StartupTimingObserver() + processor = SlowStartProcessor(delay=0.1) + + reports = [] + + @observer.event_handler("on_startup_timing_report") + async def on_report(obs, report): + reports.append(report) + + frames_to_send = [TextFrame(text="hello")] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=[TextFrame], + observers=[observer], + ) + + self.assertEqual(len(reports), 1) + report = reports[0] + self.assertGreater(report.total_duration_secs, 0) + self.assertGreater(len(report.processor_timings), 0) + + # Find our slow processor in the timings. + slow_timings = [ + t for t in report.processor_timings if "SlowStartProcessor" in t.processor_name + ] + self.assertEqual(len(slow_timings), 1) + self.assertGreaterEqual(slow_timings[0].duration_secs, 0.05) + + async def test_processor_types_filter(self): + """Test that processor_types filter limits which processors appear.""" + observer = StartupTimingObserver(processor_types=(SlowStartProcessor,)) + processor = SlowStartProcessor(delay=0.05) + + reports = [] + + @observer.event_handler("on_startup_timing_report") + async def on_report(obs, report): + reports.append(report) + + frames_to_send = [TextFrame(text="hello")] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=[TextFrame], + observers=[observer], + ) + + self.assertEqual(len(reports), 1) + report = reports[0] + + # Only SlowStartProcessor should be in the timings. + for t in report.processor_timings: + self.assertIn("SlowStartProcessor", t.processor_name) + + async def test_report_emits_once(self): + """Test that the report is emitted only once even with multiple frames.""" + observer = StartupTimingObserver() + processor = FastProcessor() + + reports = [] + + @observer.event_handler("on_startup_timing_report") + async def on_report(obs, report): + reports.append(report) + + frames_to_send = [ + TextFrame(text="first"), + TextFrame(text="second"), + TextFrame(text="third"), + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=[TextFrame, TextFrame, TextFrame], + observers=[observer], + ) + + self.assertEqual(len(reports), 1) + + async def test_event_handler_receives_report(self): + """Test that the event handler receives a proper StartupTimingReport.""" + observer = StartupTimingObserver() + processor = SlowStartProcessor(delay=0.05) + + reports = [] + + @observer.event_handler("on_startup_timing_report") + async def on_report(obs, report): + reports.append(report) + + frames_to_send = [TextFrame(text="hello")] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=[TextFrame], + observers=[observer], + ) + + self.assertEqual(len(reports), 1) + report = reports[0] + self.assertIsInstance(report, StartupTimingReport) + self.assertIsInstance(report.total_duration_secs, float) + for timing in report.processor_timings: + self.assertIsInstance(timing.processor_name, str) + self.assertIsInstance(timing.duration_secs, float) + + async def test_excludes_internal_processors(self): + """Test that internal pipeline processors are excluded by default.""" + observer = StartupTimingObserver() + processor = FastProcessor() + + reports = [] + + @observer.event_handler("on_startup_timing_report") + async def on_report(obs, report): + reports.append(report) + + frames_to_send = [TextFrame(text="hello")] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=[TextFrame], + observers=[observer], + ) + + self.assertEqual(len(reports), 1) + report = reports[0] + + # No internal processors (PipelineSource, PipelineSink, Pipeline) in the report. + internal_names = ("Pipeline#", "PipelineTask#") + for t in report.processor_timings: + for prefix in internal_names: + self.assertNotIn( + prefix, + t.processor_name, + f"Internal processor {t.processor_name} should be excluded by default", + ) + + +if __name__ == "__main__": + unittest.main() From e6b9c5c4dccbe5dd46ff9275bac19c136b15fb35 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Mon, 2 Mar 2026 12:24:10 -0500 Subject: [PATCH 26/68] Propagate Azure TTS/STT cancellation errors to the pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Azure TTS _handle_canceled was putting None (the normal completion signal) into the audio queue for all cancellation reasons, so run_tts treated errors identically to success—silently producing no audio. Now error cancellations put an Exception marker in the queue, which run_tts converts to an ErrorFrame. Azure STT had no canceled event handler at all, so auth failures, network errors, and rate-limit cancellations were invisible. Added _on_handle_canceled which pushes an ErrorFrame upstream via push_error. Fixes pipecat-ai/pipecat#3892 --- changelog/3893.fixed.md | 1 + src/pipecat/services/azure/stt.py | 12 ++++++++++++ src/pipecat/services/azure/tts.py | 11 +++++++++-- 3 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 changelog/3893.fixed.md diff --git a/changelog/3893.fixed.md b/changelog/3893.fixed.md new file mode 100644 index 000000000..0209571e3 --- /dev/null +++ b/changelog/3893.fixed.md @@ -0,0 +1 @@ +- Fixed Azure TTS and STT services silently swallowing cancellation errors (invalid API key, network failures, rate limiting) instead of propagating them as `ErrorFrame`s to the pipeline. diff --git a/src/pipecat/services/azure/stt.py b/src/pipecat/services/azure/stt.py index c6cb96d2e..5533e350e 100644 --- a/src/pipecat/services/azure/stt.py +++ b/src/pipecat/services/azure/stt.py @@ -35,6 +35,7 @@ from pipecat.utils.tracing.service_decorators import traced_stt try: from azure.cognitiveservices.speech import ( + CancellationReason, ResultReason, SpeechConfig, SpeechRecognizer, @@ -209,6 +210,7 @@ class AzureSTTService(STTService): ) self._speech_recognizer.recognizing.connect(self._on_handle_recognizing) self._speech_recognizer.recognized.connect(self._on_handle_recognized) + self._speech_recognizer.canceled.connect(self._on_handle_canceled) self._speech_recognizer.start_continuous_recognition_async() except Exception as e: await self.push_error( @@ -280,3 +282,13 @@ class AzureSTTService(STTService): result=event, ) asyncio.run_coroutine_threadsafe(self.push_frame(frame), self.get_event_loop()) + + def _on_handle_canceled(self, event): + details = event.result.cancellation_details + if details.reason == CancellationReason.Error: + error_msg = f"Azure STT recognition canceled: {details.reason}" + if details.error_details: + error_msg += f" - {details.error_details}" + asyncio.run_coroutine_threadsafe( + self.push_error(error_msg=error_msg), self.get_event_loop() + ) diff --git a/src/pipecat/services/azure/tts.py b/src/pipecat/services/azure/tts.py index f68694eb5..6e62c73bf 100644 --- a/src/pipecat/services/azure/tts.py +++ b/src/pipecat/services/azure/tts.py @@ -561,9 +561,13 @@ class AzureTTSService(TTSService, AzureBaseTTSService): # User cancellation (from interruption) is expected, not an error if reason == CancellationReason.CancelledByUser: logger.debug(f"{self}: Speech synthesis canceled by user (interruption)") + self._audio_queue.put_nowait(None) else: - logger.warning(f"{self}: Speech synthesis canceled: {reason}") - self._audio_queue.put_nowait(None) + details = evt.result.cancellation_details + error_msg = f"Azure TTS synthesis canceled: {reason}" + if details.error_details: + error_msg += f" - {details.error_details}" + self._audio_queue.put_nowait(Exception(error_msg)) async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM): """Push a frame and handle state changes. @@ -676,6 +680,9 @@ class AzureTTSService(TTSService, AzureBaseTTSService): chunk = await self._audio_queue.get() if chunk is None: # End of stream break + if isinstance(chunk, Exception): # Error from _handle_canceled + yield ErrorFrame(error=str(chunk)) + break if self._first_chunk: await self.stop_ttfb_metrics() From 58aa8e1ba56a4817fcccffd234a8533257ba8cdf Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Sat, 28 Feb 2026 14:05:25 -0500 Subject: [PATCH 27/68] Add changelog for #3881 --- changelog/3881.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/3881.added.md diff --git a/changelog/3881.added.md b/changelog/3881.added.md new file mode 100644 index 000000000..694e052ce --- /dev/null +++ b/changelog/3881.added.md @@ -0,0 +1 @@ +- Added `StartupTimingObserver` for measuring how long each processor's `start()` method takes during pipeline startup. Useful for diagnosing cold start slowness and identifying initialization bottlenecks. From 08360668984a9ae158bae566d390c2a67d17b5d5 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Sun, 1 Mar 2026 08:45:59 -0500 Subject: [PATCH 28/68] Add ClientConnectedFrame and transport readiness timing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce ClientConnectedFrame (SystemFrame) pushed by all transports when a client connects. StartupTimingObserver uses this to measure transport readiness — the time from StartFrame to first client connection — via a new on_transport_readiness_measured event. --- .../foundational/29-turn-tracking-observer.py | 4 + src/pipecat/frames/frames.py | 11 +++ .../observers/startup_timing_observer.py | 80 ++++++++++++++----- src/pipecat/transports/daily/transport.py | 3 + src/pipecat/transports/heygen/transport.py | 3 + src/pipecat/transports/livekit/transport.py | 3 + .../transports/smallwebrtc/transport.py | 3 + src/pipecat/transports/tavus/transport.py | 3 + src/pipecat/transports/websocket/fastapi.py | 2 + src/pipecat/transports/websocket/server.py | 4 +- tests/test_startup_timing_observer.py | 76 +++++++++++++++++- 11 files changed, 172 insertions(+), 20 deletions(-) diff --git a/examples/foundational/29-turn-tracking-observer.py b/examples/foundational/29-turn-tracking-observer.py index 3e85ddfb8..ad0b448e9 100644 --- a/examples/foundational/29-turn-tracking-observer.py +++ b/examples/foundational/29-turn-tracking-observer.py @@ -111,6 +111,10 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): for timing in report.processor_timings: logger.info(f" {timing.processor_name}: {timing.duration_secs:.3f}s") + @startup_observer.event_handler("on_transport_readiness_measured") + async def on_transport_readiness_measured(observer, report): + logger.info(f"Transport readiness: {report.readiness_secs:.3f}s") + turn_observer = task.turn_tracking_observer if turn_observer: diff --git a/src/pipecat/frames/frames.py b/src/pipecat/frames/frames.py index 126f3c001..b5e368c53 100644 --- a/src/pipecat/frames/frames.py +++ b/src/pipecat/frames/frames.py @@ -1910,6 +1910,17 @@ class StopFrame(ControlFrame, UninterruptibleFrame): pass +@dataclass +class ClientConnectedFrame(SystemFrame): + """Frame indicating that a client has connected to the transport. + + Pushed downstream by the input transport when a client (participant) + connects. Used by observers to measure transport readiness timing. + """ + + pass + + @dataclass class OutputTransportReadyFrame(ControlFrame): """Frame indicating that the output transport is ready. diff --git a/src/pipecat/observers/startup_timing_observer.py b/src/pipecat/observers/startup_timing_observer.py index 0f3ad0b7a..d6b1c8fa9 100644 --- a/src/pipecat/observers/startup_timing_observer.py +++ b/src/pipecat/observers/startup_timing_observer.py @@ -12,6 +12,10 @@ when a ``StartFrame`` arrives at a processor (``on_process_frame``) versus when it leaves (``on_push_frame``), giving the exact ``start()`` duration for each processor in the pipeline. +It also measures transport readiness — the time from ``StartFrame`` to the +first ``ClientConnectedFrame`` — via a separate ``on_transport_readiness_measured`` +event. + Example:: observer = StartupTimingObserver() @@ -21,6 +25,10 @@ Example:: for t in report.processor_timings: print(f"{t.processor_name}: {t.duration_secs:.3f}s") + @observer.event_handler("on_transport_readiness_measured") + async def on_readiness(observer, report): + print(f"Transport ready in {report.readiness_secs:.3f}s") + task = PipelineTask(pipeline, observers=[observer]) """ @@ -29,11 +37,11 @@ from typing import Dict, List, Optional, Tuple, Type from loguru import logger -from pipecat.frames.frames import StartFrame +from pipecat.frames.frames import ClientConnectedFrame, StartFrame from pipecat.observers.base_observer import BaseObserver, FrameProcessed, FramePushed from pipecat.pipeline.base_pipeline import BasePipeline from pipecat.pipeline.pipeline import PipelineSink, PipelineSource -from pipecat.processors.frame_processor import FrameDirection, FrameProcessor +from pipecat.processors.frame_processor import FrameProcessor # Internal pipeline types excluded from tracking by default. _INTERNAL_TYPES = (PipelineSink, PipelineSource, BasePipeline) @@ -65,6 +73,17 @@ class StartupTimingReport: processor_timings: List[ProcessorStartupTiming] = field(default_factory=list) +@dataclass +class TransportReadinessReport: + """Time from pipeline start to first client connection. + + Parameters: + readiness_secs: Seconds from StartFrame to first ClientConnectedFrame. + """ + + readiness_secs: float + + class StartupTimingObserver(BaseObserver): """Observer that measures processor startup times during pipeline initialization. @@ -73,6 +92,10 @@ class StartupTimingObserver(BaseObserver): pushed downstream. This captures WebSocket connections, API authentication, model loading, and other initialization work. + Also measures transport readiness — the time from ``StartFrame`` to the + first ``ClientConnectedFrame`` — indicating how long it takes for a client + to connect after the pipeline starts. + By default, internal pipeline processors (``PipelineSource``, ``PipelineSink``, ``Pipeline``) are excluded from the report. Pass ``processor_types`` to measure only specific types. @@ -81,6 +104,8 @@ class StartupTimingObserver(BaseObserver): - on_startup_timing_report: Called once after startup completes with the full timing report. + - on_transport_readiness_measured: Called once when the first client connects with the + transport readiness timing. Example:: @@ -93,6 +118,10 @@ class StartupTimingObserver(BaseObserver): for t in report.processor_timings: logger.info(f"{t.processor_name}: {t.duration_secs:.3f}s") + @observer.event_handler("on_transport_readiness_measured") + async def on_readiness(observer, report): + logger.info(f"Transport ready in {report.readiness_secs:.3f}s") + task = PipelineTask(pipeline, observers=[observer]) Args: @@ -125,10 +154,17 @@ class StartupTimingObserver(BaseObserver): # Lock onto the first StartFrame we see (by frame ID). self._start_frame_id: Optional[str] = None - # Whether we've already emitted the report. - self._reported = False + # Whether we've already emitted the startup timing report. + self._startup_timing_reported = False + + # Whether we've already measured transport readiness. + self._transport_readiness_measured = False + + # Timestamp (ns) when we first see a StartFrame arrive at a processor. + self._start_frame_arrival_ns: Optional[int] = None self._register_event_handler("on_startup_timing_report") + self._register_event_handler("on_transport_readiness_measured") def _should_track(self, processor: FrameProcessor) -> bool: """Check if a processor should be tracked for timing. @@ -153,18 +189,16 @@ class StartupTimingObserver(BaseObserver): Args: data: The frame processing event data. """ - if self._reported: + if self._startup_timing_reported: return if not isinstance(data.frame, StartFrame): return - if data.direction != FrameDirection.DOWNSTREAM: - return - # Lock onto the first StartFrame. if self._start_frame_id is None: self._start_frame_id = data.frame.id + self._start_frame_arrival_ns = data.timestamp elif data.frame.id != self._start_frame_id: return @@ -182,18 +216,21 @@ class StartupTimingObserver(BaseObserver): async def on_push_frame(self, data: FramePushed): """Record when a StartFrame leaves a processor and compute the delta. + Also handles ``ClientConnectedFrame`` to measure transport readiness. + Args: data: The frame push event data. """ - if self._reported: + if isinstance(data.frame, ClientConnectedFrame): + await self._handle_client_connected(data) + return + + if self._startup_timing_reported: return if not isinstance(data.frame, StartFrame): return - if data.direction != FrameDirection.DOWNSTREAM: - return - if self._start_frame_id is not None and data.frame.id != self._start_frame_id: return @@ -212,11 +249,22 @@ class StartupTimingObserver(BaseObserver): ) ) + async def _handle_client_connected(self, data: FramePushed): + """Measure transport readiness on first client connection.""" + if self._transport_readiness_measured or self._start_frame_arrival_ns is None: + return + + self._transport_readiness_measured = True + delta_ns = data.timestamp - self._start_frame_arrival_ns + readiness_secs = delta_ns / 1e9 + report = TransportReadinessReport(readiness_secs=readiness_secs) + await self._call_event_handler("on_transport_readiness_measured", report) + async def _emit_report(self): """Build and emit the startup timing report.""" - if self._reported: + if self._startup_timing_reported: return - self._reported = True + self._startup_timing_reported = True total = sum(t.duration_secs for t in self._timings) @@ -225,8 +273,4 @@ class StartupTimingObserver(BaseObserver): processor_timings=self._timings, ) - logger.debug(f"Pipeline startup completed in {total:.3f}s") - for t in self._timings: - logger.debug(f" {t.processor_name}: {t.duration_secs:.3f}s") - await self._call_event_handler("on_startup_timing_report", report) diff --git a/src/pipecat/transports/daily/transport.py b/src/pipecat/transports/daily/transport.py index 9575fd51b..cb24b23fa 100644 --- a/src/pipecat/transports/daily/transport.py +++ b/src/pipecat/transports/daily/transport.py @@ -25,6 +25,7 @@ from pydantic import BaseModel from pipecat.audio.vad.vad_analyzer import VADAnalyzer, VADParams from pipecat.frames.frames import ( CancelFrame, + ClientConnectedFrame, DataFrame, EndFrame, Frame, @@ -2716,6 +2717,8 @@ class DailyTransport(BaseTransport): await self._call_event_handler("on_participant_joined", participant) # Also call on_client_connected for compatibility with other transports await self._call_event_handler("on_client_connected", participant) + if self._input: + await self._input.push_frame(ClientConnectedFrame()) async def _on_participant_left(self, participant, reason): """Handle participant left events.""" diff --git a/src/pipecat/transports/heygen/transport.py b/src/pipecat/transports/heygen/transport.py index dbeded3e5..77ccda09f 100644 --- a/src/pipecat/transports/heygen/transport.py +++ b/src/pipecat/transports/heygen/transport.py @@ -26,6 +26,7 @@ from pipecat.frames.frames import ( BotStartedSpeakingFrame, BotStoppedSpeakingFrame, CancelFrame, + ClientConnectedFrame, EndFrame, Frame, InputAudioRawFrame, @@ -387,6 +388,8 @@ class HeyGenTransport(BaseTransport): async def _on_client_connected(self, participant: Any): """Handle client connected events.""" await self._call_event_handler("on_client_connected", participant) + if self._input: + await self._input.push_frame(ClientConnectedFrame()) async def _on_client_disconnected(self, participant: Any): """Handle client disconnected events.""" diff --git a/src/pipecat/transports/livekit/transport.py b/src/pipecat/transports/livekit/transport.py index 1902e7cd3..e4435016c 100644 --- a/src/pipecat/transports/livekit/transport.py +++ b/src/pipecat/transports/livekit/transport.py @@ -24,6 +24,7 @@ from pipecat.audio.vad.vad_analyzer import VADAnalyzer from pipecat.frames.frames import ( AudioRawFrame, CancelFrame, + ClientConnectedFrame, EndFrame, ImageRawFrame, OutputAudioRawFrame, @@ -1143,6 +1144,8 @@ class LiveKitTransport(BaseTransport): async def _on_participant_connected(self, participant_id: str): """Handle participant connected events.""" await self._call_event_handler("on_participant_connected", participant_id) + if self._input: + await self._input.push_frame(ClientConnectedFrame()) async def _on_participant_disconnected(self, participant_id: str): """Handle participant disconnected events.""" diff --git a/src/pipecat/transports/smallwebrtc/transport.py b/src/pipecat/transports/smallwebrtc/transport.py index dc91588a3..36f883278 100644 --- a/src/pipecat/transports/smallwebrtc/transport.py +++ b/src/pipecat/transports/smallwebrtc/transport.py @@ -23,6 +23,7 @@ from pydantic import BaseModel from pipecat.frames.frames import ( CancelFrame, + ClientConnectedFrame, EndFrame, Frame, InputAudioRawFrame, @@ -964,6 +965,8 @@ class SmallWebRTCTransport(BaseTransport): async def _on_client_connected(self, webrtc_connection): """Handle client connection events.""" await self._call_event_handler("on_client_connected", webrtc_connection) + if self._input: + await self._input.push_frame(ClientConnectedFrame()) async def _on_client_disconnected(self, webrtc_connection): """Handle client disconnection events.""" diff --git a/src/pipecat/transports/tavus/transport.py b/src/pipecat/transports/tavus/transport.py index dd63cb790..114f33ca0 100644 --- a/src/pipecat/transports/tavus/transport.py +++ b/src/pipecat/transports/tavus/transport.py @@ -22,6 +22,7 @@ from pydantic import BaseModel from pipecat.frames.frames import ( CancelFrame, + ClientConnectedFrame, EndFrame, Frame, InputAudioRawFrame, @@ -786,6 +787,8 @@ class TavusTransport(BaseTransport): async def _on_client_connected(self, participant: Any): """Handle client connected events.""" await self._call_event_handler("on_client_connected", participant) + if self._input: + await self._input.push_frame(ClientConnectedFrame()) async def _on_client_disconnected(self, participant: Any): """Handle client disconnected events.""" diff --git a/src/pipecat/transports/websocket/fastapi.py b/src/pipecat/transports/websocket/fastapi.py index f52123e52..0fde2b9ae 100644 --- a/src/pipecat/transports/websocket/fastapi.py +++ b/src/pipecat/transports/websocket/fastapi.py @@ -23,6 +23,7 @@ from pydantic import BaseModel from pipecat.frames.frames import ( CancelFrame, + ClientConnectedFrame, EndFrame, Frame, InputAudioRawFrame, @@ -260,6 +261,7 @@ class FastAPIWebsocketInputTransport(BaseInputTransport): if not self._monitor_websocket_task and self._params.session_timeout: self._monitor_websocket_task = self.create_task(self._monitor_websocket()) await self._client.trigger_client_connected() + await self.push_frame(ClientConnectedFrame()) if not self._receive_task: self._receive_task = self.create_task(self._receive_messages()) await self.set_transport_ready(frame) diff --git a/src/pipecat/transports/websocket/server.py b/src/pipecat/transports/websocket/server.py index e5f628fa4..fa3645d37 100644 --- a/src/pipecat/transports/websocket/server.py +++ b/src/pipecat/transports/websocket/server.py @@ -22,11 +22,11 @@ from pydantic import BaseModel from pipecat.frames.frames import ( CancelFrame, + ClientConnectedFrame, EndFrame, Frame, InputAudioRawFrame, InputTransportMessageFrame, - InputTransportMessageUrgentFrame, InterruptionFrame, OutputAudioRawFrame, OutputTransportMessageFrame, @@ -504,6 +504,8 @@ class WebsocketServerTransport(BaseTransport): if self._output: await self._output.set_client_connection(websocket) await self._call_event_handler("on_client_connected", websocket) + if self._input: + await self._input.push_frame(ClientConnectedFrame()) else: logger.error("A WebsocketServerTransport output is missing in the pipeline") diff --git a/tests/test_startup_timing_observer.py b/tests/test_startup_timing_observer.py index e3cd7c2b7..efabf5bc7 100644 --- a/tests/test_startup_timing_observer.py +++ b/tests/test_startup_timing_observer.py @@ -1,10 +1,11 @@ import asyncio import unittest -from pipecat.frames.frames import Frame, StartFrame, TextFrame +from pipecat.frames.frames import ClientConnectedFrame, Frame, StartFrame, TextFrame from pipecat.observers.startup_timing_observer import ( StartupTimingObserver, StartupTimingReport, + TransportReadinessReport, ) from pipecat.processors.frame_processor import FrameDirection, FrameProcessor from pipecat.tests.utils import run_test @@ -181,6 +182,79 @@ class TestStartupTimingObserver(unittest.IsolatedAsyncioTestCase): f"Internal processor {t.processor_name} should be excluded by default", ) + async def test_transport_readiness_measured(self): + """Test that ClientConnectedFrame after startup emits on_transport_readiness_measured.""" + observer = StartupTimingObserver() + processor = FastProcessor() + + readiness_reports = [] + + @observer.event_handler("on_transport_readiness_measured") + async def on_readiness(obs, report): + readiness_reports.append(report) + + frames_to_send = [ClientConnectedFrame(), TextFrame(text="hello")] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=[ClientConnectedFrame, TextFrame], + observers=[observer], + ) + + self.assertEqual(len(readiness_reports), 1) + report = readiness_reports[0] + self.assertIsInstance(report, TransportReadinessReport) + self.assertGreater(report.readiness_secs, 0) + + async def test_transport_readiness_only_first(self): + """Test that only the first ClientConnectedFrame triggers the event.""" + observer = StartupTimingObserver() + processor = FastProcessor() + + readiness_reports = [] + + @observer.event_handler("on_transport_readiness_measured") + async def on_readiness(obs, report): + readiness_reports.append(report) + + frames_to_send = [ + ClientConnectedFrame(), + ClientConnectedFrame(), + TextFrame(text="hello"), + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=[ClientConnectedFrame, ClientConnectedFrame, TextFrame], + observers=[observer], + ) + + self.assertEqual(len(readiness_reports), 1) + + async def test_transport_readiness_without_start_frame(self): + """Test that ClientConnectedFrame before StartFrame does not crash.""" + observer = StartupTimingObserver() + + # Directly call on_push_frame with a ClientConnectedFrame before any + # StartFrame has been seen. This should be a no-op (no crash). + from pipecat.observers.base_observer import FramePushed + + processor = FastProcessor() + destination = FastProcessor() + data = FramePushed( + source=processor, + destination=destination, + frame=ClientConnectedFrame(), + direction=FrameDirection.DOWNSTREAM, + timestamp=1000, + ) + await observer.on_push_frame(data) + + # No event should have been emitted. + self.assertFalse(observer._transport_readiness_measured) + if __name__ == "__main__": unittest.main() From de87894778e541b0f174b0be639e50e0825c2887 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Sun, 1 Mar 2026 08:47:40 -0500 Subject: [PATCH 29/68] Update changelog for #3881 --- changelog/3881.added.2.md | 1 + changelog/3881.added.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 changelog/3881.added.2.md diff --git a/changelog/3881.added.2.md b/changelog/3881.added.2.md new file mode 100644 index 000000000..a5bda94c1 --- /dev/null +++ b/changelog/3881.added.2.md @@ -0,0 +1 @@ +- Added `ClientConnectedFrame`, a new `SystemFrame` pushed by all transports (Daily, LiveKit, FastAPI WebSocket, WebSocket Server, SmallWebRTC, HeyGen, Tavus) when a client connects. Enables observers to track transport readiness timing. diff --git a/changelog/3881.added.md b/changelog/3881.added.md index 694e052ce..cbf6d0293 100644 --- a/changelog/3881.added.md +++ b/changelog/3881.added.md @@ -1 +1 @@ -- Added `StartupTimingObserver` for measuring how long each processor's `start()` method takes during pipeline startup. Useful for diagnosing cold start slowness and identifying initialization bottlenecks. +- Added `StartupTimingObserver` for measuring how long each processor's `start()` method takes during pipeline startup. Also measures transport readiness — the time from `StartFrame` to first client connection — via the `on_transport_readiness_measured` event. Useful for diagnosing cold start slowness and identifying initialization bottlenecks. From 68e8732e72ba5f20ec3b03129f97d4fa2271b190 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Mon, 2 Mar 2026 10:41:05 -0500 Subject: [PATCH 30/68] Add BotConnectedFrame and on_transport_timing_report event Add BotConnectedFrame (SystemFrame) pushed by SFU transports (Daily, LiveKit, HeyGen, Tavus) when the bot joins the room. Replace the on_transport_readiness_measured event with on_transport_timing_report which includes both bot_connected_secs and client_connected_secs. --- changelog/3881.added.3.md | 1 + .../foundational/29-turn-tracking-observer.py | 8 +- src/pipecat/frames/frames.py | 12 ++ .../observers/startup_timing_observer.py | 93 +++++++++----- src/pipecat/services/heygen/client.py | 7 +- src/pipecat/services/heygen/video.py | 5 + src/pipecat/services/tavus/video.py | 5 + src/pipecat/transports/daily/transport.py | 3 + src/pipecat/transports/heygen/transport.py | 9 ++ src/pipecat/transports/livekit/transport.py | 3 + src/pipecat/transports/tavus/transport.py | 12 ++ tests/test_startup_timing_observer.py | 114 +++++++++++++++--- 12 files changed, 215 insertions(+), 57 deletions(-) create mode 100644 changelog/3881.added.3.md diff --git a/changelog/3881.added.3.md b/changelog/3881.added.3.md new file mode 100644 index 000000000..cad26e876 --- /dev/null +++ b/changelog/3881.added.3.md @@ -0,0 +1 @@ +Added `BotConnectedFrame` for SFU transports and `on_transport_timing_report` event to `StartupTimingObserver` with bot and client connection timing. diff --git a/examples/foundational/29-turn-tracking-observer.py b/examples/foundational/29-turn-tracking-observer.py index ad0b448e9..4af28f1ed 100644 --- a/examples/foundational/29-turn-tracking-observer.py +++ b/examples/foundational/29-turn-tracking-observer.py @@ -111,9 +111,11 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): for timing in report.processor_timings: logger.info(f" {timing.processor_name}: {timing.duration_secs:.3f}s") - @startup_observer.event_handler("on_transport_readiness_measured") - async def on_transport_readiness_measured(observer, report): - logger.info(f"Transport readiness: {report.readiness_secs:.3f}s") + @startup_observer.event_handler("on_transport_timing_report") + async def on_transport_timing_report(observer, report): + if report.bot_connected_secs is not None: + logger.info(f"Bot connected: {report.bot_connected_secs:.3f}s") + logger.info(f"Client connected: {report.client_connected_secs:.3f}s") turn_observer = task.turn_tracking_observer if turn_observer: diff --git a/src/pipecat/frames/frames.py b/src/pipecat/frames/frames.py index b5e368c53..86778e564 100644 --- a/src/pipecat/frames/frames.py +++ b/src/pipecat/frames/frames.py @@ -1910,6 +1910,18 @@ class StopFrame(ControlFrame, UninterruptibleFrame): pass +@dataclass +class BotConnectedFrame(SystemFrame): + """Frame indicating the bot has connected to the transport service. + + Pushed downstream by SFU transports (Daily, LiveKit, HeyGen, Tavus) + when the bot successfully joins the room. Non-SFU transports do not + emit this frame. + """ + + pass + + @dataclass class ClientConnectedFrame(SystemFrame): """Frame indicating that a client has connected to the transport. diff --git a/src/pipecat/observers/startup_timing_observer.py b/src/pipecat/observers/startup_timing_observer.py index d6b1c8fa9..555a10cb0 100644 --- a/src/pipecat/observers/startup_timing_observer.py +++ b/src/pipecat/observers/startup_timing_observer.py @@ -12,9 +12,9 @@ when a ``StartFrame`` arrives at a processor (``on_process_frame``) versus when it leaves (``on_push_frame``), giving the exact ``start()`` duration for each processor in the pipeline. -It also measures transport readiness — the time from ``StartFrame`` to the -first ``ClientConnectedFrame`` — via a separate ``on_transport_readiness_measured`` -event. +It also measures transport timing — the time from ``StartFrame`` to the +first ``BotConnectedFrame`` (SFU transports only) and ``ClientConnectedFrame`` +— via a separate ``on_transport_timing_report`` event. Example:: @@ -25,9 +25,11 @@ Example:: for t in report.processor_timings: print(f"{t.processor_name}: {t.duration_secs:.3f}s") - @observer.event_handler("on_transport_readiness_measured") - async def on_readiness(observer, report): - print(f"Transport ready in {report.readiness_secs:.3f}s") + @observer.event_handler("on_transport_timing_report") + async def on_transport(observer, report): + if report.bot_connected_secs is not None: + print(f"Bot connected in {report.bot_connected_secs:.3f}s") + print(f"Client connected in {report.client_connected_secs:.3f}s") task = PipelineTask(pipeline, observers=[observer]) """ @@ -35,9 +37,7 @@ Example:: from dataclasses import dataclass, field from typing import Dict, List, Optional, Tuple, Type -from loguru import logger - -from pipecat.frames.frames import ClientConnectedFrame, StartFrame +from pipecat.frames.frames import BotConnectedFrame, ClientConnectedFrame, StartFrame from pipecat.observers.base_observer import BaseObserver, FrameProcessed, FramePushed from pipecat.pipeline.base_pipeline import BasePipeline from pipecat.pipeline.pipeline import PipelineSink, PipelineSource @@ -74,14 +74,17 @@ class StartupTimingReport: @dataclass -class TransportReadinessReport: - """Time from pipeline start to first client connection. +class TransportTimingReport: + """Time from pipeline start to transport connection milestones. Parameters: - readiness_secs: Seconds from StartFrame to first ClientConnectedFrame. + bot_connected_secs: Seconds from StartFrame to first BotConnectedFrame + (only set for SFU transports). + client_connected_secs: Seconds from StartFrame to first ClientConnectedFrame. """ - readiness_secs: float + bot_connected_secs: Optional[float] = None + client_connected_secs: Optional[float] = None class StartupTimingObserver(BaseObserver): @@ -92,9 +95,13 @@ class StartupTimingObserver(BaseObserver): pushed downstream. This captures WebSocket connections, API authentication, model loading, and other initialization work. - Also measures transport readiness — the time from ``StartFrame`` to the - first ``ClientConnectedFrame`` — indicating how long it takes for a client - to connect after the pipeline starts. + Also measures transport timing, the time from ``StartFrame`` to connection + milestones: + + - ``bot_connected_secs``: When the bot joins the transport room + (SFU transports only, triggered by ``BotConnectedFrame``). + - ``client_connected_secs``: When a remote participant connects + (triggered by ``ClientConnectedFrame``). By default, internal pipeline processors (``PipelineSource``, ``PipelineSink``, ``Pipeline``) are excluded from the report. Pass ``processor_types`` to @@ -104,8 +111,9 @@ class StartupTimingObserver(BaseObserver): - on_startup_timing_report: Called once after startup completes with the full timing report. - - on_transport_readiness_measured: Called once when the first client connects with the - transport readiness timing. + - on_transport_timing_report: Called once when the first client connects with a + TransportTimingReport containing client_connected_secs and bot_connected_secs + (if available). Example:: @@ -118,9 +126,11 @@ class StartupTimingObserver(BaseObserver): for t in report.processor_timings: logger.info(f"{t.processor_name}: {t.duration_secs:.3f}s") - @observer.event_handler("on_transport_readiness_measured") - async def on_readiness(observer, report): - logger.info(f"Transport ready in {report.readiness_secs:.3f}s") + @observer.event_handler("on_transport_timing_report") + async def on_transport(observer, report): + if report.bot_connected_secs is not None: + logger.info(f"Bot connected in {report.bot_connected_secs:.3f}s") + logger.info(f"Client connected in {report.client_connected_secs:.3f}s") task = PipelineTask(pipeline, observers=[observer]) @@ -157,14 +167,17 @@ class StartupTimingObserver(BaseObserver): # Whether we've already emitted the startup timing report. self._startup_timing_reported = False - # Whether we've already measured transport readiness. - self._transport_readiness_measured = False + # Whether we've already measured transport timing. + self._transport_timing_reported = False # Timestamp (ns) when we first see a StartFrame arrive at a processor. self._start_frame_arrival_ns: Optional[int] = None + # Bot connected timing (stored for inclusion in the transport report). + self._bot_connected_secs: Optional[float] = None + self._register_event_handler("on_startup_timing_report") - self._register_event_handler("on_transport_readiness_measured") + self._register_event_handler("on_transport_timing_report") def _should_track(self, processor: FrameProcessor) -> bool: """Check if a processor should be tracked for timing. @@ -216,11 +229,16 @@ class StartupTimingObserver(BaseObserver): async def on_push_frame(self, data: FramePushed): """Record when a StartFrame leaves a processor and compute the delta. - Also handles ``ClientConnectedFrame`` to measure transport readiness. + Also handles ``BotConnectedFrame`` and ``ClientConnectedFrame`` to + measure transport timing. Args: data: The frame push event data. """ + if isinstance(data.frame, BotConnectedFrame): + self._handle_bot_connected(data) + return + if isinstance(data.frame, ClientConnectedFrame): await self._handle_client_connected(data) return @@ -249,16 +267,27 @@ class StartupTimingObserver(BaseObserver): ) ) - async def _handle_client_connected(self, data: FramePushed): - """Measure transport readiness on first client connection.""" - if self._transport_readiness_measured or self._start_frame_arrival_ns is None: + def _handle_bot_connected(self, data: FramePushed): + """Record bot connected timing on first BotConnectedFrame.""" + if self._bot_connected_secs is not None or self._start_frame_arrival_ns is None: return - self._transport_readiness_measured = True delta_ns = data.timestamp - self._start_frame_arrival_ns - readiness_secs = delta_ns / 1e9 - report = TransportReadinessReport(readiness_secs=readiness_secs) - await self._call_event_handler("on_transport_readiness_measured", report) + self._bot_connected_secs = delta_ns / 1e9 + + async def _handle_client_connected(self, data: FramePushed): + """Emit transport timing report on first ClientConnectedFrame.""" + if self._transport_timing_reported or self._start_frame_arrival_ns is None: + return + + self._transport_timing_reported = True + delta_ns = data.timestamp - self._start_frame_arrival_ns + client_connected_secs = delta_ns / 1e9 + report = TransportTimingReport( + bot_connected_secs=self._bot_connected_secs, + client_connected_secs=client_connected_secs, + ) + await self._call_event_handler("on_transport_timing_report", report) async def _emit_report(self): """Build and emit the startup timing report.""" diff --git a/src/pipecat/services/heygen/client.py b/src/pipecat/services/heygen/client.py index 4018d3858..6d45d6114 100644 --- a/src/pipecat/services/heygen/client.py +++ b/src/pipecat/services/heygen/client.py @@ -62,10 +62,12 @@ class HeyGenCallbacks(BaseModel): """Callback handlers for HeyGen events. Parameters: - on_participant_connected: Called when a participant connects - on_participant_disconnected: Called when a participant disconnects + on_connected: Called when the bot connects to the LiveKit room. + on_participant_connected: Called when a participant connects. + on_participant_disconnected: Called when a participant disconnects. """ + on_connected: Callable[[], Awaitable[None]] on_participant_connected: Callable[[str], Awaitable[None]] on_participant_disconnected: Callable[[str], Awaitable[None]] @@ -251,6 +253,7 @@ class HeyGenClient: logger.debug(f"HeyGenClient send_interval: {self._send_interval}") await self._ws_connect() await self._livekit_connect() + self._call_event_callback(self._callbacks.on_connected) async def stop(self) -> None: """Stop the client and terminate all connections. diff --git a/src/pipecat/services/heygen/video.py b/src/pipecat/services/heygen/video.py index b97f4a5ed..7f3624f35 100644 --- a/src/pipecat/services/heygen/video.py +++ b/src/pipecat/services/heygen/video.py @@ -128,6 +128,7 @@ class HeyGenVideoService(AIService): session_request=self._session_request, service_type=self._service_type, callbacks=HeyGenCallbacks( + on_connected=self._on_connected, on_participant_connected=self._on_participant_connected, on_participant_disconnected=self._on_participant_disconnected, ), @@ -144,6 +145,10 @@ class HeyGenVideoService(AIService): await self._client.cleanup() self._client = None + async def _on_connected(self): + """Handle bot connected to LiveKit room.""" + logger.info("HeyGen bot connected to LiveKit room") + async def _on_participant_connected(self, participant_id: str): """Handle participant connected events.""" logger.info(f"Participant connected {participant_id}") diff --git a/src/pipecat/services/tavus/video.py b/src/pipecat/services/tavus/video.py index d9f259797..8c63ff354 100644 --- a/src/pipecat/services/tavus/video.py +++ b/src/pipecat/services/tavus/video.py @@ -94,6 +94,7 @@ class TavusVideoService(AIService): """ await super().setup(setup) callbacks = TavusCallbacks( + on_joined=self._on_joined, on_participant_joined=self._on_participant_joined, on_participant_left=self._on_participant_left, ) @@ -119,6 +120,10 @@ class TavusVideoService(AIService): await self._client.cleanup() self._client = None + async def _on_joined(self, data): + """Handle bot joined the Daily room.""" + logger.info("Tavus bot joined Daily room") + async def _on_participant_left(self, participant, reason): """Handle participant leaving the session.""" participant_id = participant["id"] diff --git a/src/pipecat/transports/daily/transport.py b/src/pipecat/transports/daily/transport.py index cb24b23fa..97aebe915 100644 --- a/src/pipecat/transports/daily/transport.py +++ b/src/pipecat/transports/daily/transport.py @@ -24,6 +24,7 @@ from pydantic import BaseModel from pipecat.audio.vad.vad_analyzer import VADAnalyzer, VADParams from pipecat.frames.frames import ( + BotConnectedFrame, CancelFrame, ClientConnectedFrame, DataFrame, @@ -2579,6 +2580,8 @@ class DailyTransport(BaseTransport): if error: await self._on_error(f"Unable to start transcription: {error}") await self._call_event_handler("on_joined", data) + if self._input: + await self._input.push_frame(BotConnectedFrame()) async def _on_left(self): """Handle room left events.""" diff --git a/src/pipecat/transports/heygen/transport.py b/src/pipecat/transports/heygen/transport.py index 77ccda09f..d79d0080e 100644 --- a/src/pipecat/transports/heygen/transport.py +++ b/src/pipecat/transports/heygen/transport.py @@ -23,6 +23,7 @@ from loguru import logger from pipecat.frames.frames import ( AudioRawFrame, + BotConnectedFrame, BotStartedSpeakingFrame, BotStoppedSpeakingFrame, CancelFrame, @@ -340,6 +341,7 @@ class HeyGenTransport(BaseTransport): session_request=session_request, service_type=service_type, callbacks=HeyGenCallbacks( + on_connected=self._on_connected, on_participant_connected=self._on_participant_connected, on_participant_disconnected=self._on_participant_disconnected, ), @@ -350,9 +352,16 @@ class HeyGenTransport(BaseTransport): # Register supported handlers. The user will only be able to register # these handlers. + self._register_event_handler("on_connected") self._register_event_handler("on_client_connected") self._register_event_handler("on_client_disconnected") + async def _on_connected(self): + """Handle bot connected to LiveKit room.""" + await self._call_event_handler("on_connected") + if self._input: + await self._input.push_frame(BotConnectedFrame()) + async def _on_participant_disconnected(self, participant_id: str): logger.debug(f"HeyGen participant {participant_id} disconnected") if participant_id != "heygen": diff --git a/src/pipecat/transports/livekit/transport.py b/src/pipecat/transports/livekit/transport.py index e4435016c..7e9c1de35 100644 --- a/src/pipecat/transports/livekit/transport.py +++ b/src/pipecat/transports/livekit/transport.py @@ -23,6 +23,7 @@ from pipecat.audio.utils import create_stream_resampler from pipecat.audio.vad.vad_analyzer import VADAnalyzer from pipecat.frames.frames import ( AudioRawFrame, + BotConnectedFrame, CancelFrame, ClientConnectedFrame, EndFrame, @@ -1132,6 +1133,8 @@ class LiveKitTransport(BaseTransport): async def _on_connected(self): """Handle room connected events.""" await self._call_event_handler("on_connected") + if self._input: + await self._input.push_frame(BotConnectedFrame()) async def _on_disconnected(self): """Handle room disconnected events.""" diff --git a/src/pipecat/transports/tavus/transport.py b/src/pipecat/transports/tavus/transport.py index 114f33ca0..6db44d431 100644 --- a/src/pipecat/transports/tavus/transport.py +++ b/src/pipecat/transports/tavus/transport.py @@ -21,6 +21,7 @@ from loguru import logger from pydantic import BaseModel from pipecat.frames.frames import ( + BotConnectedFrame, CancelFrame, ClientConnectedFrame, EndFrame, @@ -133,10 +134,12 @@ class TavusCallbacks(BaseModel): """Callback handlers for Tavus events. Parameters: + on_joined: Called when the bot joins the Daily room. on_participant_joined: Called when a participant joins the conversation. on_participant_left: Called when a participant leaves the conversation. """ + on_joined: Callable[[Mapping[str, Any]], Awaitable[None]] on_participant_joined: Callable[[Mapping[str, Any]], Awaitable[None]] on_participant_left: Callable[[Mapping[str, Any], str], Awaitable[None]] @@ -271,6 +274,7 @@ class TavusTransportClient: async def _on_joined(self, data): """Handle joined event.""" logger.debug("TavusTransportClient joined!") + await self._callbacks.on_joined(data) async def _on_left(self): """Handle left event.""" @@ -703,6 +707,7 @@ class TavusTransport(BaseTransport): self._params = params callbacks = TavusCallbacks( + on_joined=self._on_joined, on_participant_joined=self._on_participant_joined, on_participant_left=self._on_participant_left, ) @@ -721,9 +726,16 @@ class TavusTransport(BaseTransport): # Register supported handlers. The user will only be able to register # these handlers. + self._register_event_handler("on_joined") self._register_event_handler("on_client_connected") self._register_event_handler("on_client_disconnected") + async def _on_joined(self, data): + """Handle bot joined room event.""" + await self._call_event_handler("on_joined", data) + if self._input: + await self._input.push_frame(BotConnectedFrame()) + async def _on_participant_left(self, participant, reason): """Handle participant left events.""" persona_name = await self._client.get_persona_name() diff --git a/tests/test_startup_timing_observer.py b/tests/test_startup_timing_observer.py index efabf5bc7..3c89b9ca3 100644 --- a/tests/test_startup_timing_observer.py +++ b/tests/test_startup_timing_observer.py @@ -1,11 +1,17 @@ import asyncio import unittest -from pipecat.frames.frames import ClientConnectedFrame, Frame, StartFrame, TextFrame +from pipecat.frames.frames import ( + BotConnectedFrame, + ClientConnectedFrame, + Frame, + StartFrame, + TextFrame, +) from pipecat.observers.startup_timing_observer import ( StartupTimingObserver, StartupTimingReport, - TransportReadinessReport, + TransportTimingReport, ) from pipecat.processors.frame_processor import FrameDirection, FrameProcessor from pipecat.tests.utils import run_test @@ -182,16 +188,16 @@ class TestStartupTimingObserver(unittest.IsolatedAsyncioTestCase): f"Internal processor {t.processor_name} should be excluded by default", ) - async def test_transport_readiness_measured(self): - """Test that ClientConnectedFrame after startup emits on_transport_readiness_measured.""" + async def test_transport_timing_client_only(self): + """Test that ClientConnectedFrame emits on_transport_timing_report.""" observer = StartupTimingObserver() processor = FastProcessor() - readiness_reports = [] + transport_reports = [] - @observer.event_handler("on_transport_readiness_measured") - async def on_readiness(obs, report): - readiness_reports.append(report) + @observer.event_handler("on_transport_timing_report") + async def on_transport(obs, report): + transport_reports.append(report) frames_to_send = [ClientConnectedFrame(), TextFrame(text="hello")] @@ -202,21 +208,22 @@ class TestStartupTimingObserver(unittest.IsolatedAsyncioTestCase): observers=[observer], ) - self.assertEqual(len(readiness_reports), 1) - report = readiness_reports[0] - self.assertIsInstance(report, TransportReadinessReport) - self.assertGreater(report.readiness_secs, 0) + self.assertEqual(len(transport_reports), 1) + report = transport_reports[0] + self.assertIsInstance(report, TransportTimingReport) + self.assertGreater(report.client_connected_secs, 0) + self.assertIsNone(report.bot_connected_secs) - async def test_transport_readiness_only_first(self): + async def test_transport_timing_only_first_client(self): """Test that only the first ClientConnectedFrame triggers the event.""" observer = StartupTimingObserver() processor = FastProcessor() - readiness_reports = [] + transport_reports = [] - @observer.event_handler("on_transport_readiness_measured") - async def on_readiness(obs, report): - readiness_reports.append(report) + @observer.event_handler("on_transport_timing_report") + async def on_transport(obs, report): + transport_reports.append(report) frames_to_send = [ ClientConnectedFrame(), @@ -231,9 +238,9 @@ class TestStartupTimingObserver(unittest.IsolatedAsyncioTestCase): observers=[observer], ) - self.assertEqual(len(readiness_reports), 1) + self.assertEqual(len(transport_reports), 1) - async def test_transport_readiness_without_start_frame(self): + async def test_transport_timing_without_start_frame(self): """Test that ClientConnectedFrame before StartFrame does not crash.""" observer = StartupTimingObserver() @@ -253,7 +260,74 @@ class TestStartupTimingObserver(unittest.IsolatedAsyncioTestCase): await observer.on_push_frame(data) # No event should have been emitted. - self.assertFalse(observer._transport_readiness_measured) + self.assertFalse(observer._transport_timing_reported) + + async def test_bot_and_client_connected(self): + """Test that BotConnectedFrame timing is included in the transport report.""" + observer = StartupTimingObserver() + processor = FastProcessor() + + transport_reports = [] + + @observer.event_handler("on_transport_timing_report") + async def on_transport(obs, report): + transport_reports.append(report) + + frames_to_send = [ + BotConnectedFrame(), + ClientConnectedFrame(), + TextFrame(text="hello"), + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=[BotConnectedFrame, ClientConnectedFrame, TextFrame], + observers=[observer], + ) + + self.assertEqual(len(transport_reports), 1) + report = transport_reports[0] + self.assertGreater(report.client_connected_secs, 0) + self.assertIsNotNone(report.bot_connected_secs) + self.assertGreater(report.bot_connected_secs, 0) + + # Client connected should be >= bot connected. + self.assertGreaterEqual(report.client_connected_secs, report.bot_connected_secs) + + async def test_bot_connected_only_first(self): + """Test that only the first BotConnectedFrame is recorded.""" + observer = StartupTimingObserver() + processor = FastProcessor() + + transport_reports = [] + + @observer.event_handler("on_transport_timing_report") + async def on_transport(obs, report): + transport_reports.append(report) + + frames_to_send = [ + BotConnectedFrame(), + BotConnectedFrame(), + ClientConnectedFrame(), + TextFrame(text="hello"), + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=[ + BotConnectedFrame, + BotConnectedFrame, + ClientConnectedFrame, + TextFrame, + ], + observers=[observer], + ) + + # Only one transport report, with bot timing from first frame. + self.assertEqual(len(transport_reports), 1) + self.assertIsNotNone(transport_reports[0].bot_connected_secs) if __name__ == "__main__": From 75669b12a2a4a07c396932a0e67afa951733db2c Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Mon, 2 Mar 2026 11:01:26 -0500 Subject: [PATCH 31/68] Convert observer data models to Pydantic BaseModel with timestamps Switch ProcessorStartupTiming, StartupTimingReport, and TransportTimingReport from dataclasses to Pydantic BaseModel. Add start_time (Unix timestamp) fields and wall clock conversion for monotonic observer timestamps. --- .../observers/startup_timing_observer.py | 37 +++++++++++++++---- tests/test_startup_timing_observer.py | 3 ++ 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/src/pipecat/observers/startup_timing_observer.py b/src/pipecat/observers/startup_timing_observer.py index 555a10cb0..6dd574cdc 100644 --- a/src/pipecat/observers/startup_timing_observer.py +++ b/src/pipecat/observers/startup_timing_observer.py @@ -34,9 +34,11 @@ Example:: task = PipelineTask(pipeline, observers=[observer]) """ -from dataclasses import dataclass, field +import time from typing import Dict, List, Optional, Tuple, Type +from pydantic import BaseModel, Field + from pipecat.frames.frames import BotConnectedFrame, ClientConnectedFrame, StartFrame from pipecat.observers.base_observer import BaseObserver, FrameProcessed, FramePushed from pipecat.pipeline.base_pipeline import BasePipeline @@ -47,42 +49,45 @@ from pipecat.processors.frame_processor import FrameProcessor _INTERNAL_TYPES = (PipelineSink, PipelineSource, BasePipeline) -@dataclass -class ProcessorStartupTiming: +class ProcessorStartupTiming(BaseModel): """Startup timing for a single processor. Parameters: processor_name: The name of the processor. + start_time: Unix timestamp when the processor's start() began. duration_secs: How long the processor's start() took, in seconds. """ processor_name: str + start_time: float duration_secs: float -@dataclass -class StartupTimingReport: +class StartupTimingReport(BaseModel): """Report of startup timings for all measured processors. Parameters: + start_time: Unix timestamp when the first processor began starting. total_duration_secs: Total wall-clock time from first to last processor start. processor_timings: Per-processor timing data, in pipeline order. """ + start_time: float total_duration_secs: float - processor_timings: List[ProcessorStartupTiming] = field(default_factory=list) + processor_timings: List[ProcessorStartupTiming] = Field(default_factory=list) -@dataclass -class TransportTimingReport: +class TransportTimingReport(BaseModel): """Time from pipeline start to transport connection milestones. Parameters: + start_time: Unix timestamp of the StartFrame (pipeline start). bot_connected_secs: Seconds from StartFrame to first BotConnectedFrame (only set for SFU transports). client_connected_secs: Seconds from StartFrame to first ClientConnectedFrame. """ + start_time: float bot_connected_secs: Optional[float] = None client_connected_secs: Optional[float] = None @@ -176,9 +181,19 @@ class StartupTimingObserver(BaseObserver): # Bot connected timing (stored for inclusion in the transport report). self._bot_connected_secs: Optional[float] = None + # Wall clock reference for converting monotonic ns to Unix timestamps. + self._wall_clock_ref: Optional[float] = None + self._mono_clock_ref_ns: Optional[int] = None + self._register_event_handler("on_startup_timing_report") self._register_event_handler("on_transport_timing_report") + def _mono_to_wall(self, mono_ns: int) -> float: + """Convert a monotonic nanosecond timestamp to a Unix wall clock time.""" + if self._wall_clock_ref is None or self._mono_clock_ref_ns is None: + return 0.0 + return self._wall_clock_ref + (mono_ns - self._mono_clock_ref_ns) / 1e9 + def _should_track(self, processor: FrameProcessor) -> bool: """Check if a processor should be tracked for timing. @@ -212,6 +227,8 @@ class StartupTimingObserver(BaseObserver): if self._start_frame_id is None: self._start_frame_id = data.frame.id self._start_frame_arrival_ns = data.timestamp + self._wall_clock_ref = time.time() + self._mono_clock_ref_ns = data.timestamp elif data.frame.id != self._start_frame_id: return @@ -263,6 +280,7 @@ class StartupTimingObserver(BaseObserver): self._timings.append( ProcessorStartupTiming( processor_name=processor.name, + start_time=self._mono_to_wall(arrival_ts), duration_secs=duration_secs, ) ) @@ -284,6 +302,7 @@ class StartupTimingObserver(BaseObserver): delta_ns = data.timestamp - self._start_frame_arrival_ns client_connected_secs = delta_ns / 1e9 report = TransportTimingReport( + start_time=self._mono_to_wall(self._start_frame_arrival_ns), bot_connected_secs=self._bot_connected_secs, client_connected_secs=client_connected_secs, ) @@ -296,8 +315,10 @@ class StartupTimingObserver(BaseObserver): self._startup_timing_reported = True total = sum(t.duration_secs for t in self._timings) + start_time = self._timings[0].start_time if self._timings else 0.0 report = StartupTimingReport( + start_time=start_time, total_duration_secs=total, processor_timings=self._timings, ) diff --git a/tests/test_startup_timing_observer.py b/tests/test_startup_timing_observer.py index 3c89b9ca3..2bc246754 100644 --- a/tests/test_startup_timing_observer.py +++ b/tests/test_startup_timing_observer.py @@ -151,9 +151,11 @@ class TestStartupTimingObserver(unittest.IsolatedAsyncioTestCase): report = reports[0] self.assertIsInstance(report, StartupTimingReport) self.assertIsInstance(report.total_duration_secs, float) + self.assertGreater(report.start_time, 0) for timing in report.processor_timings: self.assertIsInstance(timing.processor_name, str) self.assertIsInstance(timing.duration_secs, float) + self.assertGreater(timing.start_time, 0) async def test_excludes_internal_processors(self): """Test that internal pipeline processors are excluded by default.""" @@ -211,6 +213,7 @@ class TestStartupTimingObserver(unittest.IsolatedAsyncioTestCase): self.assertEqual(len(transport_reports), 1) report = transport_reports[0] self.assertIsInstance(report, TransportTimingReport) + self.assertGreater(report.start_time, 0) self.assertGreater(report.client_connected_secs, 0) self.assertIsNone(report.bot_connected_secs) From 193f93c2cec5edd91e30a93e04181c86eaabad83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Mon, 2 Mar 2026 10:16:27 -0800 Subject: [PATCH 32/68] Update Nvidia example to use llama-3.3-70b-instruct model --- examples/foundational/07r-interruptible-nvidia.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/foundational/07r-interruptible-nvidia.py b/examples/foundational/07r-interruptible-nvidia.py index 18e0b5d5f..d3e34c61f 100644 --- a/examples/foundational/07r-interruptible-nvidia.py +++ b/examples/foundational/07r-interruptible-nvidia.py @@ -55,7 +55,8 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): stt = NvidiaSTTService(api_key=os.getenv("NVIDIA_API_KEY")) llm = NvidiaLLMService( - api_key=os.getenv("NVIDIA_API_KEY"), model="meta/llama-3.1-405b-instruct" + api_key=os.getenv("NVIDIA_API_KEY"), + model="meta/llama-3.3-70b-instruct", ) tts = NvidiaTTSService(api_key=os.getenv("NVIDIA_API_KEY")) From bbbfdfd32143940726662ca729d5d2a79637286c Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Mon, 2 Mar 2026 14:07:34 -0500 Subject: [PATCH 33/68] Replace per-processor start_time with start_offset_secs Use start_offset_secs (offset from StartFrame) on ProcessorStartupTiming instead of a wall-clock timestamp. Reports keep a single start_time anchor for dashboard visualization. Remove _mono_to_wall conversion. --- .../observers/startup_timing_observer.py | 28 ++++++++----------- tests/test_startup_timing_observer.py | 2 +- 2 files changed, 12 insertions(+), 18 deletions(-) diff --git a/src/pipecat/observers/startup_timing_observer.py b/src/pipecat/observers/startup_timing_observer.py index 6dd574cdc..8233ed2b8 100644 --- a/src/pipecat/observers/startup_timing_observer.py +++ b/src/pipecat/observers/startup_timing_observer.py @@ -54,12 +54,13 @@ class ProcessorStartupTiming(BaseModel): Parameters: processor_name: The name of the processor. - start_time: Unix timestamp when the processor's start() began. + start_offset_secs: Offset in seconds from the StartFrame to when this + processor's start() began. duration_secs: How long the processor's start() took, in seconds. """ processor_name: str - start_time: float + start_offset_secs: float duration_secs: float @@ -181,19 +182,12 @@ class StartupTimingObserver(BaseObserver): # Bot connected timing (stored for inclusion in the transport report). self._bot_connected_secs: Optional[float] = None - # Wall clock reference for converting monotonic ns to Unix timestamps. - self._wall_clock_ref: Optional[float] = None - self._mono_clock_ref_ns: Optional[int] = None + # Wall clock time when the StartFrame was first seen. + self._start_wall_clock: Optional[float] = None self._register_event_handler("on_startup_timing_report") self._register_event_handler("on_transport_timing_report") - def _mono_to_wall(self, mono_ns: int) -> float: - """Convert a monotonic nanosecond timestamp to a Unix wall clock time.""" - if self._wall_clock_ref is None or self._mono_clock_ref_ns is None: - return 0.0 - return self._wall_clock_ref + (mono_ns - self._mono_clock_ref_ns) / 1e9 - def _should_track(self, processor: FrameProcessor) -> bool: """Check if a processor should be tracked for timing. @@ -227,8 +221,7 @@ class StartupTimingObserver(BaseObserver): if self._start_frame_id is None: self._start_frame_id = data.frame.id self._start_frame_arrival_ns = data.timestamp - self._wall_clock_ref = time.time() - self._mono_clock_ref_ns = data.timestamp + self._start_wall_clock = time.time() elif data.frame.id != self._start_frame_id: return @@ -277,10 +270,12 @@ class StartupTimingObserver(BaseObserver): duration_ns = data.timestamp - arrival_ts duration_secs = duration_ns / 1e9 + start_offset_secs = (arrival_ts - self._start_frame_arrival_ns) / 1e9 + self._timings.append( ProcessorStartupTiming( processor_name=processor.name, - start_time=self._mono_to_wall(arrival_ts), + start_offset_secs=start_offset_secs, duration_secs=duration_secs, ) ) @@ -302,7 +297,7 @@ class StartupTimingObserver(BaseObserver): delta_ns = data.timestamp - self._start_frame_arrival_ns client_connected_secs = delta_ns / 1e9 report = TransportTimingReport( - start_time=self._mono_to_wall(self._start_frame_arrival_ns), + start_time=self._start_wall_clock or 0.0, bot_connected_secs=self._bot_connected_secs, client_connected_secs=client_connected_secs, ) @@ -315,10 +310,9 @@ class StartupTimingObserver(BaseObserver): self._startup_timing_reported = True total = sum(t.duration_secs for t in self._timings) - start_time = self._timings[0].start_time if self._timings else 0.0 report = StartupTimingReport( - start_time=start_time, + start_time=self._start_wall_clock or 0.0, total_duration_secs=total, processor_timings=self._timings, ) diff --git a/tests/test_startup_timing_observer.py b/tests/test_startup_timing_observer.py index 2bc246754..6355c6081 100644 --- a/tests/test_startup_timing_observer.py +++ b/tests/test_startup_timing_observer.py @@ -155,7 +155,7 @@ class TestStartupTimingObserver(unittest.IsolatedAsyncioTestCase): for timing in report.processor_timings: self.assertIsInstance(timing.processor_name, str) self.assertIsInstance(timing.duration_secs, float) - self.assertGreater(timing.start_time, 0) + self.assertGreaterEqual(timing.start_offset_secs, 0) async def test_excludes_internal_processors(self): """Test that internal pipeline processors are excluded by default.""" From 0cfd953a900f388a09ca40cedf3f49775f6d1cae Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Mon, 2 Mar 2026 14:15:41 -0500 Subject: [PATCH 34/68] Use _ArrivalInfo dataclass instead of tuple for arrival tracking --- .../observers/startup_timing_observer.py | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/src/pipecat/observers/startup_timing_observer.py b/src/pipecat/observers/startup_timing_observer.py index 8233ed2b8..d4a010d33 100644 --- a/src/pipecat/observers/startup_timing_observer.py +++ b/src/pipecat/observers/startup_timing_observer.py @@ -35,6 +35,7 @@ Example:: """ import time +from dataclasses import dataclass from typing import Dict, List, Optional, Tuple, Type from pydantic import BaseModel, Field @@ -49,6 +50,14 @@ from pipecat.processors.frame_processor import FrameProcessor _INTERNAL_TYPES = (PipelineSink, PipelineSource, BasePipeline) +@dataclass +class _ArrivalInfo: + """Internal record of when a StartFrame arrived at a processor.""" + + processor: FrameProcessor + arrival_ts_ns: int + + class ProcessorStartupTiming(BaseModel): """Startup timing for a single processor. @@ -161,8 +170,8 @@ class StartupTimingObserver(BaseObserver): super().__init__(**kwargs) self._processor_types = processor_types - # Map processor ID -> (processor, arrival_timestamp_ns) - self._arrivals: Dict[int, Tuple[FrameProcessor, int]] = {} + # Map processor ID -> arrival info. + self._arrivals: Dict[int, _ArrivalInfo] = {} # Collected timings in pipeline order. self._timings: List[ProcessorStartupTiming] = [] @@ -234,7 +243,9 @@ class StartupTimingObserver(BaseObserver): return if self._should_track(data.processor): - self._arrivals[data.processor.id] = (data.processor, data.timestamp) + self._arrivals[data.processor.id] = _ArrivalInfo( + processor=data.processor, arrival_ts_ns=data.timestamp + ) async def on_push_frame(self, data: FramePushed): """Record when a StartFrame leaves a processor and compute the delta. @@ -266,15 +277,13 @@ class StartupTimingObserver(BaseObserver): if arrival is None: return - processor, arrival_ts = arrival - duration_ns = data.timestamp - arrival_ts + duration_ns = data.timestamp - arrival.arrival_ts_ns duration_secs = duration_ns / 1e9 - - start_offset_secs = (arrival_ts - self._start_frame_arrival_ns) / 1e9 + start_offset_secs = (arrival.arrival_ts_ns - self._start_frame_arrival_ns) / 1e9 self._timings.append( ProcessorStartupTiming( - processor_name=processor.name, + processor_name=arrival.processor.name, start_offset_secs=start_offset_secs, duration_secs=duration_secs, ) From 389d0c3fb6adbbf7d926a3e3a6fea49a8aaae3d0 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Mon, 2 Mar 2026 14:33:55 -0500 Subject: [PATCH 35/68] Use on_pipeline_started from PipelineTask for startup report Replace the PipelineSink detection in StartupTimingObserver with an on_pipeline_started() callback from PipelineTask via TaskObserver. This fixes premature report emission when using ParallelPipeline, which has its own inner PipelineSinks per branch. --- src/pipecat/observers/base_observer.py | 8 +++++ .../observers/startup_timing_observer.py | 31 +++++++++---------- src/pipecat/pipeline/task.py | 1 + src/pipecat/pipeline/task_observer.py | 14 ++++++++- 4 files changed, 37 insertions(+), 17 deletions(-) diff --git a/src/pipecat/observers/base_observer.py b/src/pipecat/observers/base_observer.py index 78e36fec8..70c79224a 100644 --- a/src/pipecat/observers/base_observer.py +++ b/src/pipecat/observers/base_observer.py @@ -100,3 +100,11 @@ class BaseObserver(BaseObject): data: The event data containing details about the frame transfer. """ pass + + async def on_pipeline_started(self): + """Called when the pipeline has fully started. + + Fired after the ``StartFrame`` has been processed by all processors + in the pipeline, including nested ``ParallelPipeline`` branches. + """ + pass diff --git a/src/pipecat/observers/startup_timing_observer.py b/src/pipecat/observers/startup_timing_observer.py index d4a010d33..a1ea04d47 100644 --- a/src/pipecat/observers/startup_timing_observer.py +++ b/src/pipecat/observers/startup_timing_observer.py @@ -43,11 +43,11 @@ from pydantic import BaseModel, Field from pipecat.frames.frames import BotConnectedFrame, ClientConnectedFrame, StartFrame from pipecat.observers.base_observer import BaseObserver, FrameProcessed, FramePushed from pipecat.pipeline.base_pipeline import BasePipeline -from pipecat.pipeline.pipeline import PipelineSink, PipelineSource +from pipecat.pipeline.pipeline import PipelineSource from pipecat.processors.frame_processor import FrameProcessor # Internal pipeline types excluded from tracking by default. -_INTERNAL_TYPES = (PipelineSink, PipelineSource, BasePipeline) +_INTERNAL_TYPES = (PipelineSource, BasePipeline) @dataclass @@ -118,9 +118,9 @@ class StartupTimingObserver(BaseObserver): - ``client_connected_secs``: When a remote participant connects (triggered by ``ClientConnectedFrame``). - By default, internal pipeline processors (``PipelineSource``, ``PipelineSink``, - ``Pipeline``) are excluded from the report. Pass ``processor_types`` to - measure only specific types. + By default, internal pipeline processors (``PipelineSource``, ``Pipeline``) + are excluded from the report. Pass ``processor_types`` to measure only + specific types. Event handlers available: @@ -211,12 +211,19 @@ class StartupTimingObserver(BaseObserver): # Default: exclude internal pipeline plumbing. return not isinstance(processor, _INTERNAL_TYPES) + async def on_pipeline_started(self): + """Emit the startup timing report when the pipeline has fully started. + + Called by the ``PipelineTask`` after the ``StartFrame`` has been + processed by all processors, including nested ``ParallelPipeline`` + branches. + """ + if self._timings: + await self._emit_report() + async def on_process_frame(self, data: FrameProcessed): """Record when a StartFrame arrives at a processor. - When a ``StartFrame`` reaches a ``PipelineSink``, startup is complete - (the frame has traversed the entire pipeline) and the report is emitted. - Args: data: The frame processing event data. """ @@ -234,14 +241,6 @@ class StartupTimingObserver(BaseObserver): elif data.frame.id != self._start_frame_id: return - # When the StartFrame reaches a PipelineSink, all processors have - # completed start(). PipelineSinks use direct mode so the outermost - # sink fires last within the same synchronous call chain. - if isinstance(data.processor, PipelineSink): - if self._timings: - await self._emit_report() - return - if self._should_track(data.processor): self._arrivals[data.processor.id] = _ArrivalInfo( processor=data.processor, arrival_ts_ns=data.timestamp diff --git a/src/pipecat/pipeline/task.py b/src/pipecat/pipeline/task.py index deae6290c..906d55eb6 100644 --- a/src/pipecat/pipeline/task.py +++ b/src/pipecat/pipeline/task.py @@ -915,6 +915,7 @@ class PipelineTask(BasePipelineTask): if isinstance(frame, StartFrame): await self._call_event_handler("on_pipeline_started", frame) + await self._observer.on_pipeline_started() # Start heartbeat tasks now that StartFrame has been processed # by all processors in the pipeline diff --git a/src/pipecat/pipeline/task_observer.py b/src/pipecat/pipeline/task_observer.py index 4d33fd60e..dc2040e07 100644 --- a/src/pipecat/pipeline/task_observer.py +++ b/src/pipecat/pipeline/task_observer.py @@ -39,6 +39,12 @@ class Proxy: observer: BaseObserver +class _PipelineStartedSignal: + """Internal sentinel queued to observers when the pipeline has started.""" + + pass + + class TaskObserver(BaseObserver): """Proxy observer that manages multiple observers without blocking the pipeline. @@ -129,6 +135,10 @@ class TaskObserver(BaseObserver): for proxy in self._proxies: await proxy.cleanup() + async def on_pipeline_started(self): + """Forward pipeline started signal to all managed observers.""" + await self._send_to_proxy(_PipelineStartedSignal()) + async def on_process_frame(self, data: FrameProcessed): """Queue frame data for all managed observers. @@ -186,7 +196,9 @@ class TaskObserver(BaseObserver): while True: data = await queue.get() - if isinstance(data, FramePushed): + if isinstance(data, _PipelineStartedSignal): + await observer.on_pipeline_started() + elif isinstance(data, FramePushed): if on_push_frame_deprecated: await observer.on_push_frame( data.source, data.destination, data.frame, data.direction, data.timestamp From c1743dcffd16e05785c7c68317981280b299de94 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Mon, 2 Mar 2026 15:22:44 -0500 Subject: [PATCH 36/68] Rename Tavus event, on_connected --- src/pipecat/transports/tavus/transport.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/pipecat/transports/tavus/transport.py b/src/pipecat/transports/tavus/transport.py index 6db44d431..cb6844250 100644 --- a/src/pipecat/transports/tavus/transport.py +++ b/src/pipecat/transports/tavus/transport.py @@ -134,12 +134,12 @@ class TavusCallbacks(BaseModel): """Callback handlers for Tavus events. Parameters: - on_joined: Called when the bot joins the Daily room. + on_connected: Called when the bot connects to the room. on_participant_joined: Called when a participant joins the conversation. on_participant_left: Called when a participant leaves the conversation. """ - on_joined: Callable[[Mapping[str, Any]], Awaitable[None]] + on_connected: Callable[[Mapping[str, Any]], Awaitable[None]] on_participant_joined: Callable[[Mapping[str, Any]], Awaitable[None]] on_participant_left: Callable[[Mapping[str, Any], str], Awaitable[None]] @@ -274,7 +274,7 @@ class TavusTransportClient: async def _on_joined(self, data): """Handle joined event.""" logger.debug("TavusTransportClient joined!") - await self._callbacks.on_joined(data) + await self._callbacks.on_connected(data) async def _on_left(self): """Handle left event.""" @@ -669,6 +669,7 @@ class TavusTransport(BaseTransport): Event handlers available: + - on_connected(transport, data): Bot connected to the room - on_client_connected(transport, participant): Participant connected to the session - on_client_disconnected(transport, participant): Participant disconnected from the session @@ -707,7 +708,7 @@ class TavusTransport(BaseTransport): self._params = params callbacks = TavusCallbacks( - on_joined=self._on_joined, + on_connected=self._on_joined, on_participant_joined=self._on_participant_joined, on_participant_left=self._on_participant_left, ) @@ -726,13 +727,13 @@ class TavusTransport(BaseTransport): # Register supported handlers. The user will only be able to register # these handlers. - self._register_event_handler("on_joined") + self._register_event_handler("on_connected") self._register_event_handler("on_client_connected") self._register_event_handler("on_client_disconnected") async def _on_joined(self, data): """Handle bot joined room event.""" - await self._call_event_handler("on_joined", data) + await self._call_event_handler("on_connected", data) if self._input: await self._input.push_frame(BotConnectedFrame()) From dbdb54ce0f306a38fb0d49f0d8146d5594f9cd39 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Mon, 2 Mar 2026 15:44:37 -0500 Subject: [PATCH 37/68] Add on_connected event handler to DailyTransport for cross-transport consistency --- src/pipecat/transports/daily/transport.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/pipecat/transports/daily/transport.py b/src/pipecat/transports/daily/transport.py index 97aebe915..dc9868426 100644 --- a/src/pipecat/transports/daily/transport.py +++ b/src/pipecat/transports/daily/transport.py @@ -2072,6 +2072,8 @@ class DailyTransport(BaseTransport): Event handlers available: - on_joined: Called when the bot joins the room. Args: (data: dict) + - on_connected: Called when the bot connects to the room (alias for + on_joined). Args: (data: dict) - on_left: Called when the bot leaves the room. - on_before_leave: [sync] Called just before the bot leaves the room. - on_error: Called when a transport error occurs. Args: (error: str) @@ -2189,6 +2191,7 @@ class DailyTransport(BaseTransport): # Register supported handlers. The user will only be able to register # these handlers. self._register_event_handler("on_active_speaker_changed") + self._register_event_handler("on_connected") self._register_event_handler("on_joined") self._register_event_handler("on_left") self._register_event_handler("on_error") @@ -2580,6 +2583,8 @@ class DailyTransport(BaseTransport): if error: await self._on_error(f"Unable to start transcription: {error}") await self._call_event_handler("on_joined", data) + # Also call on_connected for compatibility with other transports + await self._call_event_handler("on_connected", data) if self._input: await self._input.push_frame(BotConnectedFrame()) From 98bd530574abe8e9b456a24fcb1b38ddbdd84ce7 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Sat, 28 Feb 2026 14:05:25 -0500 Subject: [PATCH 38/68] Add changelog for #3881 --- changelog/3881.added.md | 2 +- .../utils/tracing/service_attributes.py | 14 ++++++-------- .../utils/tracing/service_decorators.py | 19 ++++++++++++------- 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/changelog/3881.added.md b/changelog/3881.added.md index cbf6d0293..c71475675 100644 --- a/changelog/3881.added.md +++ b/changelog/3881.added.md @@ -1 +1 @@ -- Added `StartupTimingObserver` for measuring how long each processor's `start()` method takes during pipeline startup. Also measures transport readiness — the time from `StartFrame` to first client connection — via the `on_transport_readiness_measured` event. Useful for diagnosing cold start slowness and identifying initialization bottlenecks. +- Added `StartupTimingObserver` for measuring how long each processor's `start()` method takes during pipeline startup. Also measures transport readiness — the time from `StartFrame` to first client connection — via the `on_transport_timing_report` event. diff --git a/src/pipecat/utils/tracing/service_attributes.py b/src/pipecat/utils/tracing/service_attributes.py index 97ac49d87..c8471a03b 100644 --- a/src/pipecat/utils/tracing/service_attributes.py +++ b/src/pipecat/utils/tracing/service_attributes.py @@ -17,8 +17,6 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional if TYPE_CHECKING: from opentelemetry.trace import Span - from pipecat.services.settings import ServiceSettings - from pipecat.utils.tracing.setup import is_tracing_available if is_tracing_available(): @@ -70,7 +68,7 @@ def add_tts_span_attributes( model: str, voice_id: str, text: Optional[str] = None, - settings: Optional["ServiceSettings"] = None, + settings: Optional[Dict[str, Any]] = None, character_count: Optional[int] = None, operation_name: str = "tts", ttfb: Optional[float] = None, @@ -109,7 +107,7 @@ def add_tts_span_attributes( # Add settings if provided if settings: - for key, value in settings.given_fields().items(): + for key, value in settings.items(): if isinstance(value, (str, int, float, bool)): span.set_attribute(f"settings.{key}", value) @@ -128,7 +126,7 @@ def add_stt_span_attributes( is_final: Optional[bool] = None, language: Optional[str] = None, user_id: Optional[str] = None, - settings: Optional["ServiceSettings"] = None, + settings: Optional[Dict[str, Any]] = None, vad_enabled: bool = False, ttfb: Optional[float] = None, **kwargs, @@ -173,7 +171,7 @@ def add_stt_span_attributes( # Add settings if provided if settings: - for key, value in settings.given_fields().items(): + for key, value in settings.items(): if isinstance(value, (str, int, float, bool)): span.set_attribute(f"settings.{key}", value) @@ -284,7 +282,7 @@ def add_gemini_live_span_attributes( voice_id: Optional[str] = None, language: Optional[str] = None, modalities: Optional[str] = None, - settings: Optional["ServiceSettings"] = None, + settings: Optional[Dict[str, Any]] = None, tools: Optional[List[Dict]] = None, tools_serialized: Optional[str] = None, transcript: Optional[str] = None, @@ -361,7 +359,7 @@ def add_gemini_live_span_attributes( # Add settings if provided if settings: - for key, value in settings.given_fields().items(): + for key, value in settings.items(): if isinstance(value, (str, int, float, bool)): span.set_attribute(f"settings.{key}", value) elif key == "vad" and value: diff --git a/src/pipecat/utils/tracing/service_decorators.py b/src/pipecat/utils/tracing/service_decorators.py index 304ecb5e8..601cad53d 100644 --- a/src/pipecat/utils/tracing/service_decorators.py +++ b/src/pipecat/utils/tracing/service_decorators.py @@ -219,7 +219,7 @@ def traced_tts(func: Optional[Callable] = None, *, name: Optional[str] = None) - tracer = trace.get_tracer("pipecat") with tracer.start_as_current_span(span_name, context=parent_context) as span: try: - settings = getattr(self, "_settings", None) + settings = getattr(self, "_settings", {}) add_tts_span_attributes( span=span, service_name=service_class_name, @@ -338,7 +338,7 @@ def traced_stt(func: Optional[Callable] = None, *, name: Optional[str] = None) - ) # Use settings from the service if available - settings = getattr(self, "_settings", None) + settings = getattr(self, "_settings", {}) add_stt_span_attributes( span=current_span, @@ -510,10 +510,15 @@ def traced_llm(func: Optional[Callable] = None, *, name: Optional[str] = None) - # Get settings from the service params = {} if hasattr(self, "_settings"): - for key, value in self._settings.given_fields().items(): + for key, value in self._settings.items(): + if key == "extra": + continue + # Add value directly if it's a basic type if isinstance(value, (int, float, bool, str)): params[key] = value - elif value is None: + elif value is None or ( + hasattr(value, "__name__") and value.__name__ == "NOT_GIVEN" + ): params[key] = "NOT_GIVEN" # Add all available attributes to the span @@ -622,12 +627,12 @@ def traced_gemini_live(operation: str) -> Callable: model_name = _get_model_name(self) voice_id = getattr(self, "_voice_id", None) language_code = getattr(self, "_language_code", None) - settings = getattr(self, "_settings", None) + settings = getattr(self, "_settings", {}) # Get modalities if available modalities = None - if settings and hasattr(settings, "modalities"): - modality_obj = settings.modalities + if hasattr(self, "_settings") and "modalities" in self._settings: + modality_obj = self._settings["modalities"] if hasattr(modality_obj, "value"): modalities = modality_obj.value else: From ac69b3441e1161120138d43af11ca6cb12aac8c7 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Fri, 27 Feb 2026 22:35:29 -0500 Subject: [PATCH 39/68] Fix tracing to use ServiceSettings API instead of dict access The ServiceSettings refactor (PR #3714) changed self._settings from dicts to dataclass subclasses, but tracing code still used .items(), in containment, and subscript access, causing AttributeError on every traced call. Use given_fields() for iteration and attribute access for named fields. --- .../utils/tracing/service_attributes.py | 14 ++++++++------ .../utils/tracing/service_decorators.py | 19 +++++++------------ 2 files changed, 15 insertions(+), 18 deletions(-) diff --git a/src/pipecat/utils/tracing/service_attributes.py b/src/pipecat/utils/tracing/service_attributes.py index c8471a03b..97ac49d87 100644 --- a/src/pipecat/utils/tracing/service_attributes.py +++ b/src/pipecat/utils/tracing/service_attributes.py @@ -17,6 +17,8 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional if TYPE_CHECKING: from opentelemetry.trace import Span + from pipecat.services.settings import ServiceSettings + from pipecat.utils.tracing.setup import is_tracing_available if is_tracing_available(): @@ -68,7 +70,7 @@ def add_tts_span_attributes( model: str, voice_id: str, text: Optional[str] = None, - settings: Optional[Dict[str, Any]] = None, + settings: Optional["ServiceSettings"] = None, character_count: Optional[int] = None, operation_name: str = "tts", ttfb: Optional[float] = None, @@ -107,7 +109,7 @@ def add_tts_span_attributes( # Add settings if provided if settings: - for key, value in settings.items(): + for key, value in settings.given_fields().items(): if isinstance(value, (str, int, float, bool)): span.set_attribute(f"settings.{key}", value) @@ -126,7 +128,7 @@ def add_stt_span_attributes( is_final: Optional[bool] = None, language: Optional[str] = None, user_id: Optional[str] = None, - settings: Optional[Dict[str, Any]] = None, + settings: Optional["ServiceSettings"] = None, vad_enabled: bool = False, ttfb: Optional[float] = None, **kwargs, @@ -171,7 +173,7 @@ def add_stt_span_attributes( # Add settings if provided if settings: - for key, value in settings.items(): + for key, value in settings.given_fields().items(): if isinstance(value, (str, int, float, bool)): span.set_attribute(f"settings.{key}", value) @@ -282,7 +284,7 @@ def add_gemini_live_span_attributes( voice_id: Optional[str] = None, language: Optional[str] = None, modalities: Optional[str] = None, - settings: Optional[Dict[str, Any]] = None, + settings: Optional["ServiceSettings"] = None, tools: Optional[List[Dict]] = None, tools_serialized: Optional[str] = None, transcript: Optional[str] = None, @@ -359,7 +361,7 @@ def add_gemini_live_span_attributes( # Add settings if provided if settings: - for key, value in settings.items(): + for key, value in settings.given_fields().items(): if isinstance(value, (str, int, float, bool)): span.set_attribute(f"settings.{key}", value) elif key == "vad" and value: diff --git a/src/pipecat/utils/tracing/service_decorators.py b/src/pipecat/utils/tracing/service_decorators.py index 601cad53d..304ecb5e8 100644 --- a/src/pipecat/utils/tracing/service_decorators.py +++ b/src/pipecat/utils/tracing/service_decorators.py @@ -219,7 +219,7 @@ def traced_tts(func: Optional[Callable] = None, *, name: Optional[str] = None) - tracer = trace.get_tracer("pipecat") with tracer.start_as_current_span(span_name, context=parent_context) as span: try: - settings = getattr(self, "_settings", {}) + settings = getattr(self, "_settings", None) add_tts_span_attributes( span=span, service_name=service_class_name, @@ -338,7 +338,7 @@ def traced_stt(func: Optional[Callable] = None, *, name: Optional[str] = None) - ) # Use settings from the service if available - settings = getattr(self, "_settings", {}) + settings = getattr(self, "_settings", None) add_stt_span_attributes( span=current_span, @@ -510,15 +510,10 @@ def traced_llm(func: Optional[Callable] = None, *, name: Optional[str] = None) - # Get settings from the service params = {} if hasattr(self, "_settings"): - for key, value in self._settings.items(): - if key == "extra": - continue - # Add value directly if it's a basic type + for key, value in self._settings.given_fields().items(): if isinstance(value, (int, float, bool, str)): params[key] = value - elif value is None or ( - hasattr(value, "__name__") and value.__name__ == "NOT_GIVEN" - ): + elif value is None: params[key] = "NOT_GIVEN" # Add all available attributes to the span @@ -627,12 +622,12 @@ def traced_gemini_live(operation: str) -> Callable: model_name = _get_model_name(self) voice_id = getattr(self, "_voice_id", None) language_code = getattr(self, "_language_code", None) - settings = getattr(self, "_settings", {}) + settings = getattr(self, "_settings", None) # Get modalities if available modalities = None - if hasattr(self, "_settings") and "modalities" in self._settings: - modality_obj = self._settings["modalities"] + if settings and hasattr(settings, "modalities"): + modality_obj = settings.modalities if hasattr(modality_obj, "value"): modalities = modality_obj.value else: From 18155b6a633868b35c4a5210d1c0283cd143d6b0 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Sun, 1 Mar 2026 07:40:02 -0500 Subject: [PATCH 40/68] Add latency breakdown to UserBotLatencyObserver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add per-service latency breakdown metrics alongside existing user-to-bot latency measurement. When enable_metrics=True, the observer now emits an on_latency_breakdown event with TTFB, text aggregation, and user turn duration metrics collected between VADUserStoppedSpeakingFrame and BotStartedSpeakingFrame. - Add LatencyBreakdown dataclass with ttfb, text_aggregation, user_turn_secs fields - Accumulate MetricsFrame data during user→bot cycles - Reset accumulators on InterruptionFrame to discard stale metrics - Measure user_turn_secs from actual user silence (VAD timestamp - stop_secs) to turn release (UserStoppedSpeakingFrame) - Filter zero-value TTFB entries from startup metric resets - Add frame deduplication using bounded deque + set pattern - Update example 29 with latency breakdown display --- changelog/3885.added.md | 1 + .../foundational/29-turn-tracking-observer.py | 20 ++ .../observers/user_bot_latency_observer.py | 144 +++++++++-- tests/test_user_bot_latency_observer.py | 230 +++++++++++++++++- 4 files changed, 371 insertions(+), 24 deletions(-) create mode 100644 changelog/3885.added.md diff --git a/changelog/3885.added.md b/changelog/3885.added.md new file mode 100644 index 000000000..0713bbd45 --- /dev/null +++ b/changelog/3885.added.md @@ -0,0 +1 @@ +- Added `LatencyBreakdown` dataclass and `on_latency_breakdown` event to `UserBotLatencyObserver` for per-service latency metrics (TTFB, text aggregation, user turn duration) collected during each user-to-bot response cycle. diff --git a/examples/foundational/29-turn-tracking-observer.py b/examples/foundational/29-turn-tracking-observer.py index 4af28f1ed..736c68c55 100644 --- a/examples/foundational/29-turn-tracking-observer.py +++ b/examples/foundational/29-turn-tracking-observer.py @@ -131,6 +131,26 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): else: logger.info(f"🏁 Turn {turn_number} completed in {duration:.2f}s") + @latency_observer.event_handler("on_latency_breakdown") + async def on_latency_breakdown(observer, breakdown): + # Display a sequential waterfall that roughly adds up to the total. + # User turn is the first stage: user silence → turn release. + # The STT TTFB is shown as context within the user turn since + # it's a component of that time (along with VAD silence and any + # turn analyzer delay). + stt_ttfb = next((t for t in breakdown.ttfb if "STT" in t.processor), None) + if breakdown.user_turn_secs is not None: + stt_note = f" (STT: {stt_ttfb.value:.3f}s)" if stt_ttfb else "" + logger.info(f" User turn: {breakdown.user_turn_secs:.3f}s{stt_note}") + + for ttfb in breakdown.ttfb: + if ttfb is not stt_ttfb: + logger.info(f" {ttfb.processor}: TTFB {ttfb.value:.3f}s") + + if breakdown.text_aggregation: + ta = breakdown.text_aggregation + logger.info(f" {ta.processor}: text aggregation {ta.value:.3f}s") + @transport.event_handler("on_client_connected") async def on_client_connected(transport, client): logger.info(f"Client connected") diff --git a/src/pipecat/observers/user_bot_latency_observer.py b/src/pipecat/observers/user_bot_latency_observer.py index 37d5bc1a0..a7ad579f3 100644 --- a/src/pipecat/observers/user_bot_latency_observer.py +++ b/src/pipecat/observers/user_bot_latency_observer.py @@ -1,22 +1,63 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + """Observer for tracking user-to-bot response latency. This module provides an observer that monitors the time between when a user stops speaking and when the bot starts speaking, emitting events when latency -is measured. +is measured. Optionally collects per-service latency breakdown metrics +(TTFB, text aggregation) when ``enable_metrics=True``. """ import time -from typing import Optional, Set +from collections import deque +from dataclasses import dataclass, field +from typing import List, Optional from pipecat.frames.frames import ( BotStartedSpeakingFrame, + InterruptionFrame, + MetricsFrame, + UserStoppedSpeakingFrame, VADUserStartedSpeakingFrame, VADUserStoppedSpeakingFrame, ) +from pipecat.metrics.metrics import ( + TextAggregationMetricsData, + TTFBMetricsData, +) from pipecat.observers.base_observer import BaseObserver, FramePushed from pipecat.processors.frame_processor import FrameDirection +@dataclass +class LatencyBreakdown: + """Per-service latency breakdown for a single user-to-bot cycle. + + Collected between ``VADUserStoppedSpeakingFrame`` and + ``BotStartedSpeakingFrame`` when ``enable_metrics=True`` in + :class:`~pipecat.pipeline.task.PipelineParams`. + + Parameters: + ttfb: Time-to-first-byte metrics from each service in the pipeline. + text_aggregation: First text aggregation measurement, representing + the latency cost of sentence aggregation in the TTS pipeline. + user_turn_secs: Duration in seconds of the user's turn, measured + from when the user actually stopped speaking to when the turn + was released (``UserStoppedSpeakingFrame``). This includes + VAD silence detection, STT finalization, and any turn analyzer + wait. ``None`` if no ``UserStoppedSpeakingFrame`` was observed + (e.g. no turn analyzer configured). + """ + + ttfb: List[TTFBMetricsData] = field(default_factory=list) + text_aggregation: Optional[TextAggregationMetricsData] = None + user_turn_secs: Optional[float] = None + + class UserBotLatencyObserver(BaseObserver): """Observer that tracks user-to-bot response latency. @@ -25,34 +66,54 @@ class UserBotLatencyObserver(BaseObserver): latency is measured, allowing consumers to log, trace, or otherwise process the latency data. + When ``enable_metrics=True`` in pipeline params, also collects per-service + latency breakdown (TTFB, text aggregation) and emits an + ``on_latency_breakdown`` event alongside the existing latency measurement. + This observer follows the composition pattern used by TurnTrackingObserver, acting as a reusable component for latency measurement. Events: - on_latency_measured(observer, latency_seconds): Emitted when user-to-bot - latency is calculated. Includes the latency value in seconds as a float. + on_latency_measured(observer, latency_seconds): Emitted when + time-to-first-bot-speech is calculated. Measures the time from + when the user stopped speaking to when the bot starts speaking. + on_latency_breakdown(observer, breakdown): Emitted at each + ``BotStartedSpeakingFrame`` with a :class:`LatencyBreakdown` + containing per-service metrics collected during the user→bot cycle. """ - def __init__(self, **kwargs): + def __init__(self, *, max_frames=100, **kwargs): """Initialize the user-bot latency observer. Sets up tracking for processed frames and user speech timing to calculate response latencies. Args: + max_frames: Maximum number of frame IDs to keep in history for + duplicate detection. Defaults to 100. **kwargs: Additional arguments passed to parent class. """ super().__init__(**kwargs) self._user_stopped_time: Optional[float] = None - self._processed_frames: Set[str] = set() + self._user_turn: Optional[float] = None + + # Frame deduplication (bounded deque + set pattern) + self._processed_frames: set = set() + self._frame_history: deque = deque(maxlen=max_frames) + + # Per-cycle metric accumulators + self._ttfb: List[TTFBMetricsData] = [] + self._text_aggregation: Optional[TextAggregationMetricsData] = None self._register_event_handler("on_latency_measured") + self._register_event_handler("on_latency_breakdown") async def on_push_frame(self, data: FramePushed): """Process frames to track speech timing and calculate latency. Tracks VAD events and bot speaking events to measure the time between - user stopping speech and bot starting speech. + user stopping speech and bot starting speech. Also accumulates metrics + from MetricsFrame for the latency breakdown. Args: data: Frame push event containing the frame and direction information. @@ -61,23 +122,78 @@ class UserBotLatencyObserver(BaseObserver): if data.direction != FrameDirection.DOWNSTREAM: return - # Skip already processed frames + # Skip already processed frames (bounded deque + set) if data.frame.id in self._processed_frames: return self._processed_frames.add(data.frame.id) + self._frame_history.append(data.frame.id) - # Track VAD and bot speaking events for latency + if len(self._processed_frames) > len(self._frame_history): + self._processed_frames = set(self._frame_history) + + # Track speech and pipeline events for latency if isinstance(data.frame, VADUserStartedSpeakingFrame): # Reset when user starts speaking self._user_stopped_time = None + self._user_turn = None + self._reset_accumulators() elif isinstance(data.frame, VADUserStoppedSpeakingFrame): # Record the actual time the user stopped speaking, which is # the VAD determination time minus the stop_secs silence duration # that had to elapse before the VAD confirmed speech ended. self._user_stopped_time = data.frame.timestamp - data.frame.stop_secs - elif isinstance(data.frame, BotStartedSpeakingFrame) and self._user_stopped_time: - # Calculate and emit latency - latency = time.time() - self._user_stopped_time - self._user_stopped_time = None - await self._call_event_handler("on_latency_measured", latency) + elif isinstance(data.frame, UserStoppedSpeakingFrame): + # Measure the user turn duration: from actual user silence to + # turn release. Includes VAD silence detection, STT finalization, + # and any turn analyzer wait. + if self._user_stopped_time is not None: + self._user_turn = time.time() - self._user_stopped_time + elif isinstance(data.frame, InterruptionFrame): + # Discard stale metrics from cancelled LLM/TTS cycles + self._reset_accumulators() + elif isinstance(data.frame, MetricsFrame): + self._handle_metrics_frame(data.frame) + elif isinstance(data.frame, BotStartedSpeakingFrame): + await self._handle_bot_started_speaking() + + async def _handle_bot_started_speaking(self): + """Handle BotStartedSpeakingFrame to emit latency and breakdown.""" + if self._user_stopped_time is None: + return + + latency = time.time() - self._user_stopped_time + self._user_stopped_time = None + await self._call_event_handler("on_latency_measured", latency) + + breakdown = LatencyBreakdown( + ttfb=list(self._ttfb), + text_aggregation=self._text_aggregation, + user_turn_secs=self._user_turn, + ) + await self._call_event_handler("on_latency_breakdown", breakdown) + self._reset_accumulators() + + def _handle_metrics_frame(self, frame: MetricsFrame): + """Extract latency metrics from a MetricsFrame. + + Only accumulates metrics when a user→bot measurement is in progress + (after ``VADUserStoppedSpeakingFrame``). + """ + if self._user_stopped_time is None: + return + + for metrics_data in frame.data: + if isinstance(metrics_data, TTFBMetricsData) and metrics_data.value > 0: + self._ttfb.append(metrics_data) + elif isinstance(metrics_data, TextAggregationMetricsData): + # Only keep the first measurement — it's the one that + # impacts the initial speaking latency. + if self._text_aggregation is None: + self._text_aggregation = metrics_data + + def _reset_accumulators(self): + """Clear per-cycle metric accumulators.""" + self._ttfb = [] + self._text_aggregation = None + self._user_turn = None diff --git a/tests/test_user_bot_latency_observer.py b/tests/test_user_bot_latency_observer.py index 1b7325d14..8f8b2893d 100644 --- a/tests/test_user_bot_latency_observer.py +++ b/tests/test_user_bot_latency_observer.py @@ -2,12 +2,19 @@ import unittest from pipecat.frames.frames import ( BotStartedSpeakingFrame, + InterruptionFrame, + MetricsFrame, + UserStoppedSpeakingFrame, VADUserStartedSpeakingFrame, VADUserStoppedSpeakingFrame, ) +from pipecat.metrics.metrics import ( + TextAggregationMetricsData, + TTFBMetricsData, +) from pipecat.observers.user_bot_latency_observer import UserBotLatencyObserver from pipecat.processors.filters.identity_filter import IdentityFilter -from pipecat.tests.utils import run_test +from pipecat.tests.utils import SleepFrame, run_test class TestUserBotLatencyObserver(unittest.IsolatedAsyncioTestCase): @@ -97,22 +104,226 @@ class TestUserBotLatencyObserver(unittest.IsolatedAsyncioTestCase): self.assertGreater(latencies[0], 0) self.assertGreater(latencies[1], 0) - async def test_no_measurement_without_user_stop(self): - """Test that latency is not measured if bot starts without user stopping first.""" - # Create observer + async def test_breakdown_with_metrics(self): + """Test that metrics collected between VADUserStopped and BotStarted appear in breakdown.""" + observer = UserBotLatencyObserver() + processor = IdentityFilter() + + breakdowns = [] + + @observer.event_handler("on_latency_breakdown") + async def on_breakdown(obs, breakdown): + breakdowns.append(breakdown) + + stt_ttfb = TTFBMetricsData(processor="DeepgramSTTService#0", value=0.080) + llm_ttfb = TTFBMetricsData(processor="OpenAILLMService#0", model="gpt-4o", value=0.250) + tts_ttfb = TTFBMetricsData(processor="CartesiaTTSService#0", value=0.070) + text_agg = TextAggregationMetricsData(processor="CartesiaTTSService#0", value=0.030) + + frames_to_send = [ + VADUserStoppedSpeakingFrame(), + MetricsFrame(data=[stt_ttfb]), + MetricsFrame(data=[llm_ttfb, text_agg]), + MetricsFrame(data=[tts_ttfb]), + BotStartedSpeakingFrame(), + ] + + expected_down_frames = [ + VADUserStoppedSpeakingFrame, + MetricsFrame, + MetricsFrame, + MetricsFrame, + BotStartedSpeakingFrame, + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + observers=[observer], + ) + + self.assertEqual(len(breakdowns), 1) + bd = breakdowns[0] + self.assertEqual(len(bd.ttfb), 3) + self.assertEqual(bd.ttfb[0].processor, "DeepgramSTTService#0") + self.assertEqual(bd.ttfb[1].processor, "OpenAILLMService#0") + self.assertEqual(bd.ttfb[2].processor, "CartesiaTTSService#0") + self.assertIsNotNone(bd.text_aggregation) + self.assertEqual(bd.text_aggregation.value, 0.030) + + async def test_interruption_resets_accumulators(self): + """Test that InterruptionFrame clears stale metrics from earlier cycles.""" + observer = UserBotLatencyObserver() + processor = IdentityFilter() + + breakdowns = [] + + @observer.event_handler("on_latency_breakdown") + async def on_breakdown(obs, breakdown): + breakdowns.append(breakdown) + + # First cycle metrics (will be interrupted) + stale_llm = TTFBMetricsData(processor="OpenAILLMService#0", value=0.245) + # Second cycle metrics (the ones that matter) + final_llm = TTFBMetricsData(processor="OpenAILLMService#0", value=0.224) + final_tts = TTFBMetricsData(processor="CartesiaTTSService#0", value=0.142) + + frames_to_send = [ + VADUserStoppedSpeakingFrame(), + MetricsFrame(data=[stale_llm]), + InterruptionFrame(), + MetricsFrame(data=[final_llm]), + MetricsFrame(data=[final_tts]), + BotStartedSpeakingFrame(), + ] + + expected_down_frames = [ + VADUserStoppedSpeakingFrame, + MetricsFrame, + InterruptionFrame, + MetricsFrame, + MetricsFrame, + BotStartedSpeakingFrame, + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + observers=[observer], + ) + + self.assertEqual(len(breakdowns), 1) + bd = breakdowns[0] + # Only the post-interruption metrics should be present + self.assertEqual(len(bd.ttfb), 2) + self.assertEqual(bd.ttfb[0].processor, "OpenAILLMService#0") + self.assertEqual(bd.ttfb[0].value, 0.224) + self.assertEqual(bd.ttfb[1].processor, "CartesiaTTSService#0") + self.assertEqual(bd.ttfb[1].value, 0.142) + + async def test_only_first_text_aggregation_kept(self): + """Test that only the first text aggregation metric is kept per cycle.""" + observer = UserBotLatencyObserver() + processor = IdentityFilter() + + breakdowns = [] + + @observer.event_handler("on_latency_breakdown") + async def on_breakdown(obs, breakdown): + breakdowns.append(breakdown) + + text_agg_1 = TextAggregationMetricsData(processor="CartesiaTTSService#0", value=0.030) + text_agg_2 = TextAggregationMetricsData(processor="CartesiaTTSService#0", value=0.080) + + frames_to_send = [ + VADUserStoppedSpeakingFrame(), + MetricsFrame(data=[text_agg_1]), + MetricsFrame(data=[text_agg_2]), + BotStartedSpeakingFrame(), + ] + + expected_down_frames = [ + VADUserStoppedSpeakingFrame, + MetricsFrame, + MetricsFrame, + BotStartedSpeakingFrame, + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + observers=[observer], + ) + + self.assertEqual(len(breakdowns), 1) + self.assertIsNotNone(breakdowns[0].text_aggregation) + self.assertEqual(breakdowns[0].text_aggregation.value, 0.030) + + async def test_user_turn_measured(self): + """Test that pre-LLM wait from user silence to UserStopped is captured.""" + observer = UserBotLatencyObserver() + processor = IdentityFilter() + + breakdowns = [] + + @observer.event_handler("on_latency_breakdown") + async def on_breakdown(obs, breakdown): + breakdowns.append(breakdown) + + frames_to_send = [ + VADUserStoppedSpeakingFrame(), + SleepFrame(sleep=0.1), # Simulate turn analyzer wait + UserStoppedSpeakingFrame(), + BotStartedSpeakingFrame(), + ] + + expected_down_frames = [ + VADUserStoppedSpeakingFrame, + UserStoppedSpeakingFrame, + BotStartedSpeakingFrame, + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + observers=[observer], + ) + + self.assertEqual(len(breakdowns), 1) + self.assertIsNotNone(breakdowns[0].user_turn_secs) + self.assertGreaterEqual(breakdowns[0].user_turn_secs, 0.1) + + async def test_user_turn_none_without_user_stopped(self): + """Test that user_turn is None when no UserStoppedSpeakingFrame arrives.""" + observer = UserBotLatencyObserver() + processor = IdentityFilter() + + breakdowns = [] + + @observer.event_handler("on_latency_breakdown") + async def on_breakdown(obs, breakdown): + breakdowns.append(breakdown) + + frames_to_send = [ + VADUserStoppedSpeakingFrame(), + BotStartedSpeakingFrame(), + ] + + expected_down_frames = [ + VADUserStoppedSpeakingFrame, + BotStartedSpeakingFrame, + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + observers=[observer], + ) + + self.assertEqual(len(breakdowns), 1) + self.assertIsNone(breakdowns[0].user_turn_secs) + + async def test_no_measurement_without_user_stop(self): + """Test that BotStartedSpeaking without prior user stop emits nothing.""" observer = UserBotLatencyObserver() - - # Create identity filter processor = IdentityFilter() - # Capture latency events latencies = [] + breakdowns = [] @observer.event_handler("on_latency_measured") async def on_latency(obs, latency_seconds): latencies.append(latency_seconds) - # Define frame sequence - bot starts without user stop + @observer.event_handler("on_latency_breakdown") + async def on_breakdown(obs, breakdown): + breakdowns.append(breakdown) + frames_to_send = [ BotStartedSpeakingFrame(), ] @@ -121,7 +332,6 @@ class TestUserBotLatencyObserver(unittest.IsolatedAsyncioTestCase): BotStartedSpeakingFrame, ] - # Run test await run_test( processor, frames_to_send=frames_to_send, @@ -129,8 +339,8 @@ class TestUserBotLatencyObserver(unittest.IsolatedAsyncioTestCase): observers=[observer], ) - # Verify no latency was measured self.assertEqual(len(latencies), 0) + self.assertEqual(len(breakdowns), 0) if __name__ == "__main__": From ddba1b84a937cceb6ebe602ebd67b42f3a2a374b Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Sun, 1 Mar 2026 09:11:10 -0500 Subject: [PATCH 41/68] Add first-bot-speech latency to UserBotLatencyObserver Measure time from ClientConnectedFrame to first BotStartedSpeakingFrame, emitting a one-time on_first_bot_speech_latency event with breakdown. --- changelog/3885.added.2.md | 1 + changelog/3885.added.md | 2 +- .../foundational/29-turn-tracking-observer.py | 4 + .../observers/user_bot_latency_observer.py | 62 ++++++--- tests/test_user_bot_latency_observer.py | 121 ++++++++++++++++++ 5 files changed, 174 insertions(+), 16 deletions(-) create mode 100644 changelog/3885.added.2.md diff --git a/changelog/3885.added.2.md b/changelog/3885.added.2.md new file mode 100644 index 000000000..a9562b883 --- /dev/null +++ b/changelog/3885.added.2.md @@ -0,0 +1 @@ +- Added `on_first_bot_speech_latency` event to `UserBotLatencyObserver` measuring the time from client connection to first bot speech, including a latency breakdown with per-service metrics. diff --git a/changelog/3885.added.md b/changelog/3885.added.md index 0713bbd45..87cbd7824 100644 --- a/changelog/3885.added.md +++ b/changelog/3885.added.md @@ -1 +1 @@ -- Added `LatencyBreakdown` dataclass and `on_latency_breakdown` event to `UserBotLatencyObserver` for per-service latency metrics (TTFB, text aggregation, user turn duration) collected during each user-to-bot response cycle. +- Added `on_latency_breakdown` event to `UserBotLatencyObserver` providing per-service TTFB, text aggregation, and user turn duration metrics for each user-to-bot response cycle. diff --git a/examples/foundational/29-turn-tracking-observer.py b/examples/foundational/29-turn-tracking-observer.py index 736c68c55..2c8ec1f84 100644 --- a/examples/foundational/29-turn-tracking-observer.py +++ b/examples/foundational/29-turn-tracking-observer.py @@ -101,6 +101,10 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): observers=[latency_observer, startup_observer], ) + @latency_observer.event_handler("on_first_bot_speech_latency") + async def on_first_bot_speech_latency(observer, latency_seconds): + logger.info(f"First bot speech: {latency_seconds:.3f}s after client connected") + @latency_observer.event_handler("on_latency_measured") async def on_latency_measured(observer, latency_seconds): logger.info(f"⏱️ User-to-bot latency: {latency_seconds:.3f}s") diff --git a/src/pipecat/observers/user_bot_latency_observer.py b/src/pipecat/observers/user_bot_latency_observer.py index a7ad579f3..cdc7c43d0 100644 --- a/src/pipecat/observers/user_bot_latency_observer.py +++ b/src/pipecat/observers/user_bot_latency_observer.py @@ -19,6 +19,7 @@ from typing import List, Optional from pipecat.frames.frames import ( BotStartedSpeakingFrame, + ClientConnectedFrame, InterruptionFrame, MetricsFrame, UserStoppedSpeakingFrame, @@ -80,6 +81,10 @@ class UserBotLatencyObserver(BaseObserver): on_latency_breakdown(observer, breakdown): Emitted at each ``BotStartedSpeakingFrame`` with a :class:`LatencyBreakdown` containing per-service metrics collected during the user→bot cycle. + on_first_bot_speech_latency(observer, latency_seconds): Emitted once, + the first time ``BotStartedSpeakingFrame`` arrives after + ``ClientConnectedFrame``. Measures the time from client connection + to the first bot speech. """ def __init__(self, *, max_frames=100, **kwargs): @@ -97,6 +102,10 @@ class UserBotLatencyObserver(BaseObserver): self._user_stopped_time: Optional[float] = None self._user_turn: Optional[float] = None + # First bot speech tracking + self._client_connected_time: Optional[float] = None + self._first_bot_speech_measured: bool = False + # Frame deduplication (bounded deque + set pattern) self._processed_frames: set = set() self._frame_history: deque = deque(maxlen=max_frames) @@ -107,6 +116,7 @@ class UserBotLatencyObserver(BaseObserver): self._register_event_handler("on_latency_measured") self._register_event_handler("on_latency_breakdown") + self._register_event_handler("on_first_bot_speech_latency") async def on_push_frame(self, data: FramePushed): """Process frames to track speech timing and calculate latency. @@ -132,12 +142,21 @@ class UserBotLatencyObserver(BaseObserver): if len(self._processed_frames) > len(self._frame_history): self._processed_frames = set(self._frame_history) + # Track client connection (first occurrence only) + if isinstance(data.frame, ClientConnectedFrame): + if self._client_connected_time is None: + self._client_connected_time = time.time() + return + # Track speech and pipeline events for latency if isinstance(data.frame, VADUserStartedSpeakingFrame): # Reset when user starts speaking self._user_stopped_time = None self._user_turn = None self._reset_accumulators() + # If user speaks before the bot's first speech, abandon the + # first-bot-speech measurement — it's only meaningful for greetings. + self._first_bot_speech_measured = True elif isinstance(data.frame, VADUserStoppedSpeakingFrame): # Record the actual time the user stopped speaking, which is # the VAD determination time minus the stop_secs silence duration @@ -159,28 +178,41 @@ class UserBotLatencyObserver(BaseObserver): async def _handle_bot_started_speaking(self): """Handle BotStartedSpeakingFrame to emit latency and breakdown.""" - if self._user_stopped_time is None: - return + emit_breakdown = False - latency = time.time() - self._user_stopped_time - self._user_stopped_time = None - await self._call_event_handler("on_latency_measured", latency) + # One-time first bot speech measurement (client connect → first speech) + if self._client_connected_time is not None and not self._first_bot_speech_measured: + self._first_bot_speech_measured = True + latency = time.time() - self._client_connected_time + await self._call_event_handler("on_first_bot_speech_latency", latency) + emit_breakdown = True - breakdown = LatencyBreakdown( - ttfb=list(self._ttfb), - text_aggregation=self._text_aggregation, - user_turn_secs=self._user_turn, - ) - await self._call_event_handler("on_latency_breakdown", breakdown) - self._reset_accumulators() + if self._user_stopped_time is not None: + latency = time.time() - self._user_stopped_time + self._user_stopped_time = None + await self._call_event_handler("on_latency_measured", latency) + emit_breakdown = True + + if emit_breakdown: + breakdown = LatencyBreakdown( + ttfb=list(self._ttfb), + text_aggregation=self._text_aggregation, + user_turn_secs=self._user_turn, + ) + await self._call_event_handler("on_latency_breakdown", breakdown) + self._reset_accumulators() def _handle_metrics_frame(self, frame: MetricsFrame): """Extract latency metrics from a MetricsFrame. - Only accumulates metrics when a user→bot measurement is in progress - (after ``VADUserStoppedSpeakingFrame``). + Accumulates metrics when a measurement is in progress: either a + user→bot cycle (after ``VADUserStoppedSpeakingFrame``) or the + first-bot-speech window (after ``ClientConnectedFrame``). """ - if self._user_stopped_time is None: + waiting_for_first_speech = ( + self._client_connected_time is not None and not self._first_bot_speech_measured + ) + if self._user_stopped_time is None and not waiting_for_first_speech: return for metrics_data in frame.data: diff --git a/tests/test_user_bot_latency_observer.py b/tests/test_user_bot_latency_observer.py index 8f8b2893d..ab00bfd61 100644 --- a/tests/test_user_bot_latency_observer.py +++ b/tests/test_user_bot_latency_observer.py @@ -2,6 +2,7 @@ import unittest from pipecat.frames.frames import ( BotStartedSpeakingFrame, + ClientConnectedFrame, InterruptionFrame, MetricsFrame, UserStoppedSpeakingFrame, @@ -342,6 +343,126 @@ class TestUserBotLatencyObserver(unittest.IsolatedAsyncioTestCase): self.assertEqual(len(latencies), 0) self.assertEqual(len(breakdowns), 0) + async def test_first_bot_speech_latency(self): + """Test first bot speech latency and breakdown from ClientConnected to BotStartedSpeaking.""" + observer = UserBotLatencyObserver() + processor = IdentityFilter() + + first_speech_latencies = [] + breakdowns = [] + + @observer.event_handler("on_first_bot_speech_latency") + async def on_first_bot_speech(obs, latency_seconds): + first_speech_latencies.append(latency_seconds) + + @observer.event_handler("on_latency_breakdown") + async def on_breakdown(obs, breakdown): + breakdowns.append(breakdown) + + llm_ttfb = TTFBMetricsData(processor="OpenAILLMService#0", value=0.250) + tts_ttfb = TTFBMetricsData(processor="CartesiaTTSService#0", value=0.070) + + frames_to_send = [ + ClientConnectedFrame(), + MetricsFrame(data=[llm_ttfb]), + MetricsFrame(data=[tts_ttfb]), + BotStartedSpeakingFrame(), + ] + + expected_down_frames = [ + ClientConnectedFrame, + MetricsFrame, + MetricsFrame, + BotStartedSpeakingFrame, + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + observers=[observer], + ) + + self.assertEqual(len(first_speech_latencies), 1) + self.assertGreater(first_speech_latencies[0], 0) + self.assertLess(first_speech_latencies[0], 1.0) + + # Breakdown should also be emitted with the accumulated metrics + self.assertEqual(len(breakdowns), 1) + self.assertEqual(len(breakdowns[0].ttfb), 2) + self.assertEqual(breakdowns[0].ttfb[0].processor, "OpenAILLMService#0") + self.assertEqual(breakdowns[0].ttfb[1].processor, "CartesiaTTSService#0") + + async def test_first_bot_speech_only_once(self): + """Test that first bot speech latency is only emitted once.""" + observer = UserBotLatencyObserver() + processor = IdentityFilter() + + first_speech_latencies = [] + + @observer.event_handler("on_first_bot_speech_latency") + async def on_first_bot_speech(obs, latency_seconds): + first_speech_latencies.append(latency_seconds) + + frames_to_send = [ + ClientConnectedFrame(), + BotStartedSpeakingFrame(), + # Second bot speech should not trigger the event again + VADUserStoppedSpeakingFrame(), + BotStartedSpeakingFrame(), + ] + + expected_down_frames = [ + ClientConnectedFrame, + BotStartedSpeakingFrame, + VADUserStoppedSpeakingFrame, + BotStartedSpeakingFrame, + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + observers=[observer], + ) + + self.assertEqual(len(first_speech_latencies), 1) + + async def test_first_bot_speech_skipped_when_user_speaks_first(self): + """Test that first bot speech event is not emitted when user speaks before the bot.""" + observer = UserBotLatencyObserver() + processor = IdentityFilter() + + first_speech_latencies = [] + + @observer.event_handler("on_first_bot_speech_latency") + async def on_first_bot_speech(obs, latency_seconds): + first_speech_latencies.append(latency_seconds) + + frames_to_send = [ + ClientConnectedFrame(), + # User speaks before bot has a chance to greet + VADUserStartedSpeakingFrame(), + VADUserStoppedSpeakingFrame(), + BotStartedSpeakingFrame(), + ] + + expected_down_frames = [ + ClientConnectedFrame, + VADUserStartedSpeakingFrame, + VADUserStoppedSpeakingFrame, + BotStartedSpeakingFrame, + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + expected_down_frames=expected_down_frames, + observers=[observer], + ) + + self.assertEqual(len(first_speech_latencies), 0) + if __name__ == "__main__": unittest.main() From a738a4d82b44d6b6e0c670376db769127abd41e3 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Sun, 1 Mar 2026 11:14:17 -0500 Subject: [PATCH 42/68] Add function call latency tracking to LatencyBreakdown --- changelog/3885.added.md | 2 +- .../foundational/29-turn-tracking-observer.py | 62 +++++++++++++- .../observers/user_bot_latency_observer.py | 40 ++++++++- tests/test_user_bot_latency_observer.py | 81 +++++++++++++++++++ 4 files changed, 179 insertions(+), 6 deletions(-) diff --git a/changelog/3885.added.md b/changelog/3885.added.md index 87cbd7824..96f8cc2cd 100644 --- a/changelog/3885.added.md +++ b/changelog/3885.added.md @@ -1 +1 @@ -- Added `on_latency_breakdown` event to `UserBotLatencyObserver` providing per-service TTFB, text aggregation, and user turn duration metrics for each user-to-bot response cycle. +- Added `on_latency_breakdown` event to `UserBotLatencyObserver` providing per-service TTFB, text aggregation, user turn duration, and function call latency metrics for each user-to-bot response cycle. diff --git a/examples/foundational/29-turn-tracking-observer.py b/examples/foundational/29-turn-tracking-observer.py index 2c8ec1f84..8bec9e2bc 100644 --- a/examples/foundational/29-turn-tracking-observer.py +++ b/examples/foundational/29-turn-tracking-observer.py @@ -5,11 +5,14 @@ # +import asyncio import os from dotenv import load_dotenv from loguru import logger +from pipecat.adapters.schemas.function_schema import FunctionSchema +from pipecat.adapters.schemas.tools_schema import ToolsSchema from pipecat.audio.vad.silero import SileroVADAnalyzer from pipecat.frames.frames import LLMRunFrame from pipecat.observers.startup_timing_observer import StartupTimingObserver @@ -26,6 +29,7 @@ from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport from pipecat.services.cartesia.tts import CartesiaTTSService from pipecat.services.deepgram.stt import DeepgramSTTService +from pipecat.services.llm_service import FunctionCallParams from pipecat.services.openai.llm import OpenAILLMService from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.daily.transport import DailyParams @@ -33,6 +37,17 @@ from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams load_dotenv(override=True) + +async def fetch_weather_from_api(params: FunctionCallParams): + await asyncio.sleep(0.25) + await params.result_callback({"conditions": "nice", "temperature": "75"}) + + +async def fetch_restaurant_recommendation(params: FunctionCallParams): + await asyncio.sleep(0.1) + await params.result_callback({"name": "The Golden Dragon"}) + + # We use lambdas to defer transport parameter creation until the transport # type is selected at runtime. transport_params = { @@ -63,6 +78,38 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) + llm.register_function("get_current_weather", fetch_weather_from_api) + llm.register_function("get_restaurant_recommendation", fetch_restaurant_recommendation) + + weather_function = FunctionSchema( + name="get_current_weather", + description="Get the current weather", + properties={ + "location": { + "type": "string", + "description": "The city and state, e.g. San Francisco, CA", + }, + "format": { + "type": "string", + "enum": ["celsius", "fahrenheit"], + "description": "The temperature unit to use. Infer this from the user's location.", + }, + }, + required=["location", "format"], + ) + restaurant_function = FunctionSchema( + name="get_restaurant_recommendation", + description="Get a restaurant recommendation", + properties={ + "location": { + "type": "string", + "description": "The city and state, e.g. San Francisco, CA", + }, + }, + required=["location"], + ) + tools = ToolsSchema(standard_tools=[weather_function, restaurant_function]) + messages = [ { "role": "system", @@ -70,7 +117,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): }, ] - context = LLMContext(messages) + context = LLMContext(messages, tools) user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), @@ -147,9 +194,16 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): stt_note = f" (STT: {stt_ttfb.value:.3f}s)" if stt_ttfb else "" logger.info(f" User turn: {breakdown.user_turn_secs:.3f}s{stt_note}") - for ttfb in breakdown.ttfb: - if ttfb is not stt_ttfb: - logger.info(f" {ttfb.processor}: TTFB {ttfb.value:.3f}s") + # Show non-STT TTFBs, inserting function calls after the first + # LLM TTFB (which triggered the calls) for a chronological waterfall. + non_stt = [t for t in breakdown.ttfb if t is not stt_ttfb] + fc_shown = False + for ttfb in non_stt: + logger.info(f" {ttfb.processor}: TTFB {ttfb.value:.3f}s") + if not fc_shown and breakdown.function_calls: + for fc in breakdown.function_calls: + logger.info(f" {fc.function_name}: {fc.duration_secs:.3f}s") + fc_shown = True if breakdown.text_aggregation: ta = breakdown.text_aggregation diff --git a/src/pipecat/observers/user_bot_latency_observer.py b/src/pipecat/observers/user_bot_latency_observer.py index cdc7c43d0..46dbbd0f1 100644 --- a/src/pipecat/observers/user_bot_latency_observer.py +++ b/src/pipecat/observers/user_bot_latency_observer.py @@ -15,11 +15,13 @@ is measured. Optionally collects per-service latency breakdown metrics import time from collections import deque from dataclasses import dataclass, field -from typing import List, Optional +from typing import Dict, List, Optional from pipecat.frames.frames import ( BotStartedSpeakingFrame, ClientConnectedFrame, + FunctionCallInProgressFrame, + FunctionCallResultFrame, InterruptionFrame, MetricsFrame, UserStoppedSpeakingFrame, @@ -34,6 +36,19 @@ from pipecat.observers.base_observer import BaseObserver, FramePushed from pipecat.processors.frame_processor import FrameDirection +@dataclass +class FunctionCallMetrics: + """Latency for a single function call execution. + + Parameters: + function_name: Name of the function that was called. + duration_secs: Time in seconds from execution start to result. + """ + + function_name: str + duration_secs: float + + @dataclass class LatencyBreakdown: """Per-service latency breakdown for a single user-to-bot cycle. @@ -52,11 +67,14 @@ class LatencyBreakdown: VAD silence detection, STT finalization, and any turn analyzer wait. ``None`` if no ``UserStoppedSpeakingFrame`` was observed (e.g. no turn analyzer configured). + function_calls: Latency for each function call executed during + this cycle. Empty if no function calls occurred. """ ttfb: List[TTFBMetricsData] = field(default_factory=list) text_aggregation: Optional[TextAggregationMetricsData] = None user_turn_secs: Optional[float] = None + function_calls: List[FunctionCallMetrics] = field(default_factory=list) class UserBotLatencyObserver(BaseObserver): @@ -113,6 +131,8 @@ class UserBotLatencyObserver(BaseObserver): # Per-cycle metric accumulators self._ttfb: List[TTFBMetricsData] = [] self._text_aggregation: Optional[TextAggregationMetricsData] = None + self._function_call_starts: Dict[str, tuple[str, float]] = {} + self._function_call_metrics: List[FunctionCallMetrics] = [] self._register_event_handler("on_latency_measured") self._register_event_handler("on_latency_breakdown") @@ -171,6 +191,21 @@ class UserBotLatencyObserver(BaseObserver): elif isinstance(data.frame, InterruptionFrame): # Discard stale metrics from cancelled LLM/TTS cycles self._reset_accumulators() + elif isinstance(data.frame, FunctionCallInProgressFrame): + self._function_call_starts[data.frame.tool_call_id] = ( + data.frame.function_name, + time.time(), + ) + elif isinstance(data.frame, FunctionCallResultFrame): + start = self._function_call_starts.pop(data.frame.tool_call_id, None) + if start is not None: + function_name, start_time = start + self._function_call_metrics.append( + FunctionCallMetrics( + function_name=function_name, + duration_secs=time.time() - start_time, + ) + ) elif isinstance(data.frame, MetricsFrame): self._handle_metrics_frame(data.frame) elif isinstance(data.frame, BotStartedSpeakingFrame): @@ -198,6 +233,7 @@ class UserBotLatencyObserver(BaseObserver): ttfb=list(self._ttfb), text_aggregation=self._text_aggregation, user_turn_secs=self._user_turn, + function_calls=list(self._function_call_metrics), ) await self._call_event_handler("on_latency_breakdown", breakdown) self._reset_accumulators() @@ -229,3 +265,5 @@ class UserBotLatencyObserver(BaseObserver): self._ttfb = [] self._text_aggregation = None self._user_turn = None + self._function_call_starts = {} + self._function_call_metrics = [] diff --git a/tests/test_user_bot_latency_observer.py b/tests/test_user_bot_latency_observer.py index ab00bfd61..8e63647cf 100644 --- a/tests/test_user_bot_latency_observer.py +++ b/tests/test_user_bot_latency_observer.py @@ -3,6 +3,8 @@ import unittest from pipecat.frames.frames import ( BotStartedSpeakingFrame, ClientConnectedFrame, + FunctionCallInProgressFrame, + FunctionCallResultFrame, InterruptionFrame, MetricsFrame, UserStoppedSpeakingFrame, @@ -463,6 +465,85 @@ class TestUserBotLatencyObserver(unittest.IsolatedAsyncioTestCase): self.assertEqual(len(first_speech_latencies), 0) + async def test_function_call_latency_in_breakdown(self): + """Test that function call duration appears in the latency breakdown.""" + observer = UserBotLatencyObserver() + processor = IdentityFilter() + + breakdowns = [] + + @observer.event_handler("on_latency_breakdown") + async def on_breakdown(obs, breakdown): + breakdowns.append(breakdown) + + tool_call_id = "call_abc123" + + frames_to_send = [ + VADUserStoppedSpeakingFrame(), + FunctionCallInProgressFrame( + function_name="get_weather", + tool_call_id=tool_call_id, + arguments={"location": "Atlanta"}, + ), + SleepFrame(sleep=0.1), + FunctionCallResultFrame( + function_name="get_weather", + tool_call_id=tool_call_id, + arguments={"location": "Atlanta"}, + result={"temperature": "75"}, + ), + BotStartedSpeakingFrame(), + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + observers=[observer], + ) + + self.assertEqual(len(breakdowns), 1) + self.assertEqual(len(breakdowns[0].function_calls), 1) + fc = breakdowns[0].function_calls[0] + self.assertEqual(fc.function_name, "get_weather") + self.assertGreaterEqual(fc.duration_secs, 0.1) + + async def test_function_call_reset_on_interruption(self): + """Test that function call metrics are cleared on interruption.""" + observer = UserBotLatencyObserver() + processor = IdentityFilter() + + breakdowns = [] + + @observer.event_handler("on_latency_breakdown") + async def on_breakdown(obs, breakdown): + breakdowns.append(breakdown) + + frames_to_send = [ + VADUserStoppedSpeakingFrame(), + FunctionCallInProgressFrame( + function_name="get_weather", + tool_call_id="call_1", + arguments={}, + ), + FunctionCallResultFrame( + function_name="get_weather", + tool_call_id="call_1", + arguments={}, + result={}, + ), + InterruptionFrame(), + BotStartedSpeakingFrame(), + ] + + await run_test( + processor, + frames_to_send=frames_to_send, + observers=[observer], + ) + + self.assertEqual(len(breakdowns), 1) + self.assertEqual(len(breakdowns[0].function_calls), 0) + if __name__ == "__main__": unittest.main() From ff5b9850096d2d2226b19f5d905f6417958e1af4 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Sun, 1 Mar 2026 11:51:27 -0500 Subject: [PATCH 43/68] Convert observer data models to Pydantic BaseModel with timestamps Enables .model_dump() serialization for Pipecat Cloud collection. All metrics now include start_time (Unix timestamp) for timeline plotting alongside duration_secs. --- .../foundational/29-turn-tracking-observer.py | 6 +- .../observers/user_bot_latency_observer.py | 77 ++++++++++++++++--- tests/test_user_bot_latency_observer.py | 8 +- 3 files changed, 72 insertions(+), 19 deletions(-) diff --git a/examples/foundational/29-turn-tracking-observer.py b/examples/foundational/29-turn-tracking-observer.py index 8bec9e2bc..476dc4612 100644 --- a/examples/foundational/29-turn-tracking-observer.py +++ b/examples/foundational/29-turn-tracking-observer.py @@ -191,7 +191,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): # turn analyzer delay). stt_ttfb = next((t for t in breakdown.ttfb if "STT" in t.processor), None) if breakdown.user_turn_secs is not None: - stt_note = f" (STT: {stt_ttfb.value:.3f}s)" if stt_ttfb else "" + stt_note = f" (STT: {stt_ttfb.duration_secs:.3f}s)" if stt_ttfb else "" logger.info(f" User turn: {breakdown.user_turn_secs:.3f}s{stt_note}") # Show non-STT TTFBs, inserting function calls after the first @@ -199,7 +199,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): non_stt = [t for t in breakdown.ttfb if t is not stt_ttfb] fc_shown = False for ttfb in non_stt: - logger.info(f" {ttfb.processor}: TTFB {ttfb.value:.3f}s") + logger.info(f" {ttfb.processor}: TTFB {ttfb.duration_secs:.3f}s") if not fc_shown and breakdown.function_calls: for fc in breakdown.function_calls: logger.info(f" {fc.function_name}: {fc.duration_secs:.3f}s") @@ -207,7 +207,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): if breakdown.text_aggregation: ta = breakdown.text_aggregation - logger.info(f" {ta.processor}: text aggregation {ta.value:.3f}s") + logger.info(f" {ta.processor}: text aggregation {ta.duration_secs:.3f}s") @transport.event_handler("on_client_connected") async def on_client_connected(transport, client): diff --git a/src/pipecat/observers/user_bot_latency_observer.py b/src/pipecat/observers/user_bot_latency_observer.py index 46dbbd0f1..aa0887e30 100644 --- a/src/pipecat/observers/user_bot_latency_observer.py +++ b/src/pipecat/observers/user_bot_latency_observer.py @@ -14,9 +14,10 @@ is measured. Optionally collects per-service latency breakdown metrics import time from collections import deque -from dataclasses import dataclass, field from typing import Dict, List, Optional +from pydantic import BaseModel, Field + from pipecat.frames.frames import ( BotStartedSpeakingFrame, ClientConnectedFrame, @@ -36,21 +37,51 @@ from pipecat.observers.base_observer import BaseObserver, FramePushed from pipecat.processors.frame_processor import FrameDirection -@dataclass -class FunctionCallMetrics: +class TTFBBreakdownMetrics(BaseModel): + """TTFB measurement with timestamp for timeline placement. + + Parameters: + processor: Name of the processor that reported the TTFB. + model: Optional model name associated with the metric. + start_time: Unix timestamp when the TTFB measurement started. + duration_secs: TTFB duration in seconds. + """ + + processor: str + model: Optional[str] = None + start_time: float + duration_secs: float + + +class TextAggregationBreakdownMetrics(BaseModel): + """Text aggregation measurement with timestamp for timeline placement. + + Parameters: + processor: Name of the processor that reported the metric. + start_time: Unix timestamp when text aggregation started. + duration_secs: Aggregation duration in seconds. + """ + + processor: str + start_time: float + duration_secs: float + + +class FunctionCallMetrics(BaseModel): """Latency for a single function call execution. Parameters: function_name: Name of the function that was called. + start_time: Unix timestamp when execution started. duration_secs: Time in seconds from execution start to result. """ function_name: str + start_time: float duration_secs: float -@dataclass -class LatencyBreakdown: +class LatencyBreakdown(BaseModel): """Per-service latency breakdown for a single user-to-bot cycle. Collected between ``VADUserStoppedSpeakingFrame`` and @@ -61,6 +92,9 @@ class LatencyBreakdown: ttfb: Time-to-first-byte metrics from each service in the pipeline. text_aggregation: First text aggregation measurement, representing the latency cost of sentence aggregation in the TTS pipeline. + user_turn_start_time: Unix timestamp when the user turn started + (actual user silence, adjusted for VAD stop_secs). ``None`` if + no ``VADUserStoppedSpeakingFrame`` was observed. user_turn_secs: Duration in seconds of the user's turn, measured from when the user actually stopped speaking to when the turn was released (``UserStoppedSpeakingFrame``). This includes @@ -71,10 +105,11 @@ class LatencyBreakdown: this cycle. Empty if no function calls occurred. """ - ttfb: List[TTFBMetricsData] = field(default_factory=list) - text_aggregation: Optional[TextAggregationMetricsData] = None + ttfb: List[TTFBBreakdownMetrics] = Field(default_factory=list) + text_aggregation: Optional[TextAggregationBreakdownMetrics] = None + user_turn_start_time: Optional[float] = None user_turn_secs: Optional[float] = None - function_calls: List[FunctionCallMetrics] = field(default_factory=list) + function_calls: List[FunctionCallMetrics] = Field(default_factory=list) class UserBotLatencyObserver(BaseObserver): @@ -118,6 +153,7 @@ class UserBotLatencyObserver(BaseObserver): """ super().__init__(**kwargs) self._user_stopped_time: Optional[float] = None + self._user_turn_start_time: Optional[float] = None self._user_turn: Optional[float] = None # First bot speech tracking @@ -129,8 +165,8 @@ class UserBotLatencyObserver(BaseObserver): self._frame_history: deque = deque(maxlen=max_frames) # Per-cycle metric accumulators - self._ttfb: List[TTFBMetricsData] = [] - self._text_aggregation: Optional[TextAggregationMetricsData] = None + self._ttfb: List[TTFBBreakdownMetrics] = [] + self._text_aggregation: Optional[TextAggregationBreakdownMetrics] = None self._function_call_starts: Dict[str, tuple[str, float]] = {} self._function_call_metrics: List[FunctionCallMetrics] = [] @@ -172,6 +208,7 @@ class UserBotLatencyObserver(BaseObserver): if isinstance(data.frame, VADUserStartedSpeakingFrame): # Reset when user starts speaking self._user_stopped_time = None + self._user_turn_start_time = None self._user_turn = None self._reset_accumulators() # If user speaks before the bot's first speech, abandon the @@ -182,6 +219,7 @@ class UserBotLatencyObserver(BaseObserver): # the VAD determination time minus the stop_secs silence duration # that had to elapse before the VAD confirmed speech ended. self._user_stopped_time = data.frame.timestamp - data.frame.stop_secs + self._user_turn_start_time = self._user_stopped_time elif isinstance(data.frame, UserStoppedSpeakingFrame): # Measure the user turn duration: from actual user silence to # turn release. Includes VAD silence detection, STT finalization, @@ -203,6 +241,7 @@ class UserBotLatencyObserver(BaseObserver): self._function_call_metrics.append( FunctionCallMetrics( function_name=function_name, + start_time=start_time, duration_secs=time.time() - start_time, ) ) @@ -232,6 +271,7 @@ class UserBotLatencyObserver(BaseObserver): breakdown = LatencyBreakdown( ttfb=list(self._ttfb), text_aggregation=self._text_aggregation, + user_turn_start_time=self._user_turn_start_time, user_turn_secs=self._user_turn, function_calls=list(self._function_call_metrics), ) @@ -251,19 +291,32 @@ class UserBotLatencyObserver(BaseObserver): if self._user_stopped_time is None and not waiting_for_first_speech: return + now = time.time() for metrics_data in frame.data: if isinstance(metrics_data, TTFBMetricsData) and metrics_data.value > 0: - self._ttfb.append(metrics_data) + self._ttfb.append( + TTFBBreakdownMetrics( + processor=metrics_data.processor, + model=metrics_data.model, + start_time=now - metrics_data.value, + duration_secs=metrics_data.value, + ) + ) elif isinstance(metrics_data, TextAggregationMetricsData): # Only keep the first measurement — it's the one that # impacts the initial speaking latency. if self._text_aggregation is None: - self._text_aggregation = metrics_data + self._text_aggregation = TextAggregationBreakdownMetrics( + processor=metrics_data.processor, + start_time=now - metrics_data.value, + duration_secs=metrics_data.value, + ) def _reset_accumulators(self): """Clear per-cycle metric accumulators.""" self._ttfb = [] self._text_aggregation = None + self._user_turn_start_time = None self._user_turn = None self._function_call_starts = {} self._function_call_metrics = [] diff --git a/tests/test_user_bot_latency_observer.py b/tests/test_user_bot_latency_observer.py index 8e63647cf..42d5d3367 100644 --- a/tests/test_user_bot_latency_observer.py +++ b/tests/test_user_bot_latency_observer.py @@ -153,7 +153,7 @@ class TestUserBotLatencyObserver(unittest.IsolatedAsyncioTestCase): self.assertEqual(bd.ttfb[1].processor, "OpenAILLMService#0") self.assertEqual(bd.ttfb[2].processor, "CartesiaTTSService#0") self.assertIsNotNone(bd.text_aggregation) - self.assertEqual(bd.text_aggregation.value, 0.030) + self.assertEqual(bd.text_aggregation.duration_secs, 0.030) async def test_interruption_resets_accumulators(self): """Test that InterruptionFrame clears stale metrics from earlier cycles.""" @@ -202,9 +202,9 @@ class TestUserBotLatencyObserver(unittest.IsolatedAsyncioTestCase): # Only the post-interruption metrics should be present self.assertEqual(len(bd.ttfb), 2) self.assertEqual(bd.ttfb[0].processor, "OpenAILLMService#0") - self.assertEqual(bd.ttfb[0].value, 0.224) + self.assertEqual(bd.ttfb[0].duration_secs, 0.224) self.assertEqual(bd.ttfb[1].processor, "CartesiaTTSService#0") - self.assertEqual(bd.ttfb[1].value, 0.142) + self.assertEqual(bd.ttfb[1].duration_secs, 0.142) async def test_only_first_text_aggregation_kept(self): """Test that only the first text aggregation metric is kept per cycle.""" @@ -243,7 +243,7 @@ class TestUserBotLatencyObserver(unittest.IsolatedAsyncioTestCase): self.assertEqual(len(breakdowns), 1) self.assertIsNotNone(breakdowns[0].text_aggregation) - self.assertEqual(breakdowns[0].text_aggregation.value, 0.030) + self.assertEqual(breakdowns[0].text_aggregation.duration_secs, 0.030) async def test_user_turn_measured(self): """Test that pre-LLM wait from user silence to UserStopped is captured.""" From 8f66272de7079818aba54b0ea74804052b030ae4 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Mon, 2 Mar 2026 16:16:38 -0500 Subject: [PATCH 44/68] Update changelog --- changelog/3885.added.2.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/3885.added.2.md b/changelog/3885.added.2.md index a9562b883..5a6adce12 100644 --- a/changelog/3885.added.2.md +++ b/changelog/3885.added.2.md @@ -1 +1 @@ -- Added `on_first_bot_speech_latency` event to `UserBotLatencyObserver` measuring the time from client connection to first bot speech, including a latency breakdown with per-service metrics. +- Added `on_first_bot_speech_latency` event to `UserBotLatencyObserver` measuring the time from client connection to first bot speech. An `on_latency_breakdown` is also emitted for this first speech event. From d0ecb3c7a8bee801878015f99000d8d8a68403fc Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Mon, 2 Mar 2026 16:24:21 -0500 Subject: [PATCH 45/68] Revert "Deprecate processing metrics (ProcessingMetricsData)" (#3852) This reverts commit 127b52bad5309d5e6b3df90a7100f69b49f81c55. --- changelog/3852.deprecated.md | 1 - src/pipecat/metrics/metrics.py | 4 ---- src/pipecat/processors/frame_processor.py | 16 ---------------- .../metrics/frame_processor_metrics.py | 8 -------- 4 files changed, 29 deletions(-) delete mode 100644 changelog/3852.deprecated.md diff --git a/changelog/3852.deprecated.md b/changelog/3852.deprecated.md deleted file mode 100644 index 666c7c58a..000000000 --- a/changelog/3852.deprecated.md +++ /dev/null @@ -1 +0,0 @@ -- Deprecated `ProcessingMetricsData` and `start_processing_metrics()`/`stop_processing_metrics()` on `FrameProcessor` and `FrameProcessorMetrics`. These metrics don't accurately depict a service's performance. Instead, TTFB metrics are recommended. Processing metrics will be removed in the 1.0.0 version. diff --git a/src/pipecat/metrics/metrics.py b/src/pipecat/metrics/metrics.py index 37ab99447..2030306e5 100644 --- a/src/pipecat/metrics/metrics.py +++ b/src/pipecat/metrics/metrics.py @@ -41,10 +41,6 @@ class TTFBMetricsData(MetricsData): class ProcessingMetricsData(MetricsData): """General processing time metrics data. - .. deprecated:: 0.0.104 - Processing metrics are deprecated and will be removed in a future version. - Use TTFB metrics instead. - Parameters: value: Processing time measurement in seconds. """ diff --git a/src/pipecat/processors/frame_processor.py b/src/pipecat/processors/frame_processor.py index 3e7b48442..bfe818696 100644 --- a/src/pipecat/processors/frame_processor.py +++ b/src/pipecat/processors/frame_processor.py @@ -441,35 +441,19 @@ class FrameProcessor(BaseObject): if frame: await self.push_frame(frame) - _processing_metrics_warned = False - async def start_processing_metrics(self, *, start_time: Optional[float] = None): """Start processing metrics collection. - .. deprecated:: 0.0.104 - Processing metrics are deprecated and will be removed in a future version. - Use TTFB metrics instead. - Args: start_time: Optional timestamp to use as the start time. If None, uses the current time. """ if self.can_generate_metrics() and self.metrics_enabled: - if not FrameProcessor._processing_metrics_warned: - FrameProcessor._processing_metrics_warned = True - logger.warning( - "Processing metrics are deprecated and will be removed in a future version. " - "Use TTFB metrics instead." - ) await self._metrics.start_processing_metrics(start_time=start_time) async def stop_processing_metrics(self, *, end_time: Optional[float] = None): """Stop processing metrics collection and push results. - .. deprecated:: 0.0.104 - Processing metrics are deprecated and will be removed in a future version. - Use TTFB metrics instead. - Args: end_time: Optional timestamp to use as the end time. If None, uses the current time. diff --git a/src/pipecat/processors/metrics/frame_processor_metrics.py b/src/pipecat/processors/metrics/frame_processor_metrics.py index ef637b5ad..7a52895a2 100644 --- a/src/pipecat/processors/metrics/frame_processor_metrics.py +++ b/src/pipecat/processors/metrics/frame_processor_metrics.py @@ -150,10 +150,6 @@ class FrameProcessorMetrics(BaseObject): async def start_processing_metrics(self, *, start_time: Optional[float] = None): """Start measuring processing time. - .. deprecated:: 0.0.104 - Processing metrics are deprecated and will be removed in a future version. - Use TTFB metrics instead. - Args: start_time: Optional timestamp to use as the start time. If None, uses the current time. @@ -163,10 +159,6 @@ class FrameProcessorMetrics(BaseObject): async def stop_processing_metrics(self, *, end_time: Optional[float] = None): """Stop processing time measurement and generate metrics frame. - .. deprecated:: 0.0.104 - Processing metrics are deprecated and will be removed in a future version. - Use TTFB metrics instead. - Args: end_time: Optional timestamp to use as the end time. If None, uses the current time. From 4a61d5bfadc55651e921be8bb1ac40affbacd929 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Mon, 2 Mar 2026 12:04:51 -0800 Subject: [PATCH 46/68] Add broadcast_interruption() to FrameProcessor Replace the round-trip push_interruption_task_frame_and_wait() mechanism with broadcast_interruption(), which pushes an InterruptionFrame both upstream and downstream directly from the calling processor. This eliminates race conditions (transcription arriving before the InterruptionFrame comes back), swallowed-event timeouts (frame blocked before reaching the sink), and the complexity of _wait_for_interruption flag / queue bypass / frame.complete() obligations. - Add broadcast_interruption() to FrameProcessor - Deprecate push_interruption_task_frame_and_wait() (delegates to new method) - Remove event field and complete() from InterruptionFrame/InterruptionTaskFrame - Remove _wait_for_interruption flag and all special-case logic - Remove frame.complete() calls in stt_mute_filter and llm_response_universal - Update all 17 call sites to use broadcast_interruption() - Update tests --- changelog/3900.added.md | 1 + changelog/3900.changed.md | 1 + changelog/3900.deprecated.md | 1 + .../voicemail/voicemail_detector.py | 2 +- src/pipecat/frames/frames.py | 29 +---- src/pipecat/pipeline/task.py | 4 +- .../processors/aggregators/dtmf_aggregator.py | 2 +- .../processors/aggregators/llm_response.py | 2 +- .../aggregators/llm_response_universal.py | 8 +- .../processors/filters/stt_mute_filter.py | 6 - src/pipecat/processors/frame_processor.py | 77 ++++-------- src/pipecat/processors/frameworks/rtvi.py | 2 +- src/pipecat/services/deepgram/flux/stt.py | 2 +- src/pipecat/services/deepgram/stt.py | 2 +- src/pipecat/services/gladia/stt.py | 2 +- .../services/google/gemini_live/llm.py | 2 +- src/pipecat/services/grok/realtime/llm.py | 2 +- src/pipecat/services/openai/realtime/llm.py | 2 +- src/pipecat/services/openai/stt.py | 2 +- .../services/openai_realtime_beta/openai.py | 2 +- src/pipecat/services/sarvam/stt.py | 2 +- src/pipecat/services/speechmatics/stt.py | 2 +- src/pipecat/transports/base_input.py | 2 +- src/pipecat/turns/user_turn_processor.py | 2 +- tests/test_context_aggregators.py | 3 +- tests/test_frame_processor.py | 117 +++--------------- tests/test_stt_mute_filter.py | 13 +- 27 files changed, 68 insertions(+), 224 deletions(-) create mode 100644 changelog/3900.added.md create mode 100644 changelog/3900.changed.md create mode 100644 changelog/3900.deprecated.md diff --git a/changelog/3900.added.md b/changelog/3900.added.md new file mode 100644 index 000000000..08921c004 --- /dev/null +++ b/changelog/3900.added.md @@ -0,0 +1 @@ +- Added `broadcast_interruption()` to `FrameProcessor`. This method pushes an `InterruptionFrame` both upstream and downstream directly from the calling processor, avoiding the round-trip through the pipeline task that `push_interruption_task_frame_and_wait()` required. diff --git a/changelog/3900.changed.md b/changelog/3900.changed.md new file mode 100644 index 000000000..59b4cdb95 --- /dev/null +++ b/changelog/3900.changed.md @@ -0,0 +1 @@ +- Removed `event` field and `complete()` method from `InterruptionFrame`. Removed `event` field from `InterruptionTaskFrame`. These are no longer needed since `broadcast_interruption()` does not require a round-trip completion signal. diff --git a/changelog/3900.deprecated.md b/changelog/3900.deprecated.md new file mode 100644 index 000000000..421e10e92 --- /dev/null +++ b/changelog/3900.deprecated.md @@ -0,0 +1 @@ +- Deprecated `push_interruption_task_frame_and_wait()` in `FrameProcessor`. Use `broadcast_interruption()` instead. The old method now delegates to `broadcast_interruption()` and logs a deprecation warning. diff --git a/src/pipecat/extensions/voicemail/voicemail_detector.py b/src/pipecat/extensions/voicemail/voicemail_detector.py index 7e22e535a..470f5dd54 100644 --- a/src/pipecat/extensions/voicemail/voicemail_detector.py +++ b/src/pipecat/extensions/voicemail/voicemail_detector.py @@ -368,7 +368,7 @@ class ClassificationProcessor(FrameProcessor): await self._voicemail_notifier.notify() # Clear buffered TTS frames # Interrupt the current pipeline to stop any ongoing processing - await self.push_interruption_task_frame_and_wait() + await self.broadcast_interruption() # Set the voicemail event to trigger the voicemail handler self._voicemail_event.clear() diff --git a/src/pipecat/frames/frames.py b/src/pipecat/frames/frames.py index 126f3c001..9d6f78d6c 100644 --- a/src/pipecat/frames/frames.py +++ b/src/pipecat/frames/frames.py @@ -11,7 +11,6 @@ including data frames, system frames, and control frames for audio, video, text, and LLM processing. """ -import asyncio import time from dataclasses import dataclass, field from typing import ( @@ -1141,24 +1140,9 @@ class InterruptionFrame(SystemFrame): This frame is used to interrupt the pipeline. For example, when a user starts speaking to cancel any in-progress bot output. It can also be pushed by any processor. - - Parameters: - event: Optional event set when the frame has fully traversed the - pipeline. - """ - event: Optional[asyncio.Event] = None - - def complete(self): - """Signal that this interruption has been fully processed. - - Called automatically when the frame reaches the pipeline sink, or - manually when the frame is consumed before reaching it (e.g. when - the user is muted). - """ - if self.event: - self.event.set() + pass @dataclass @@ -1825,16 +1809,11 @@ class InterruptionTaskFrame(TaskFrame): """Frame indicating the pipeline should be interrupted. This frame should be pushed upstream to indicate the pipeline should be - interrupted. The pipeline task converts this into an `InterruptionFrame` and - sends it downstream. The `event` is passed to the `InterruptionFrame` so it - can signal when the interruption has fully traversed the pipeline. - - Parameters: - event: Optional event passed to the corresponding `InterruptionFrame`. - + interrupted. The pipeline task converts this into an `InterruptionFrame` + and sends it downstream. """ - event: Optional[asyncio.Event] = None + pass @dataclass diff --git a/src/pipecat/pipeline/task.py b/src/pipecat/pipeline/task.py index deae6290c..291ed5506 100644 --- a/src/pipecat/pipeline/task.py +++ b/src/pipecat/pipeline/task.py @@ -892,7 +892,7 @@ class PipelineTask(BasePipelineTask): # pipeline. This is in case the push task is blocked waiting for a # pipeline-ending frame to finish traversing the pipeline. logger.debug(f"{self}: received interruption task frame {frame}") - await self._pipeline.queue_frame(InterruptionFrame(event=frame.event)) + await self._pipeline.queue_frame(InterruptionFrame()) elif isinstance(frame, ErrorFrame): await self._call_event_handler("on_pipeline_error", frame) if frame.fatal: @@ -931,8 +931,6 @@ class PipelineTask(BasePipelineTask): self._pipeline_end_event.set() elif isinstance(frame, CancelFrame): self._pipeline_end_event.set() - elif isinstance(frame, InterruptionFrame): - frame.complete() elif isinstance(frame, HeartbeatFrame): await self._heartbeat_queue.put(frame) diff --git a/src/pipecat/processors/aggregators/dtmf_aggregator.py b/src/pipecat/processors/aggregators/dtmf_aggregator.py index 1b9c59158..ea56ba6fc 100644 --- a/src/pipecat/processors/aggregators/dtmf_aggregator.py +++ b/src/pipecat/processors/aggregators/dtmf_aggregator.py @@ -104,7 +104,7 @@ class DTMFAggregator(FrameProcessor): # For first digit, schedule interruption. if is_first_digit: - await self.push_interruption_task_frame_and_wait() + await self.broadcast_interruption() # Check for immediate flush conditions if frame.button == self._termination_digit: diff --git a/src/pipecat/processors/aggregators/llm_response.py b/src/pipecat/processors/aggregators/llm_response.py index 44e5ce252..7c246b209 100644 --- a/src/pipecat/processors/aggregators/llm_response.py +++ b/src/pipecat/processors/aggregators/llm_response.py @@ -581,7 +581,7 @@ class LLMUserContextAggregator(LLMContextResponseAggregator): logger.debug( "Interruption conditions met - pushing interruption and aggregation" ) - await self.push_interruption_task_frame_and_wait() + await self.broadcast_interruption() await self._process_aggregation() else: logger.debug("Interruption conditions not met - not pushing aggregation") diff --git a/src/pipecat/processors/aggregators/llm_response_universal.py b/src/pipecat/processors/aggregators/llm_response_universal.py index 96f3702be..cf6c81e5f 100644 --- a/src/pipecat/processors/aggregators/llm_response_universal.py +++ b/src/pipecat/processors/aggregators/llm_response_universal.py @@ -608,12 +608,6 @@ class LLMUserAggregator(LLMContextAggregator): if should_mute_frame: logger.trace(f"{frame.name} suppressed - user currently muted") - # When muted, the InterruptionFrame won't propagate further and - # will never reach the pipeline sink. Complete it here so - # push_interruption_task_frame_and_wait() doesn't hang. - if should_mute_frame and isinstance(frame, InterruptionFrame): - frame.complete() - should_mute_next_time = False for s in self._params.user_mute_strategies: should_mute_next_time |= await s.process_frame(frame) @@ -737,7 +731,7 @@ class LLMUserAggregator(LLMContextAggregator): await self._user_idle_controller.process_frame(UserStartedSpeakingFrame()) if params.enable_interruptions and self._allow_interruptions: - await self.push_interruption_task_frame_and_wait() + await self.broadcast_interruption() await self._call_event_handler("on_user_turn_started", strategy) diff --git a/src/pipecat/processors/filters/stt_mute_filter.py b/src/pipecat/processors/filters/stt_mute_filter.py index f5d008e28..9f522a20d 100644 --- a/src/pipecat/processors/filters/stt_mute_filter.py +++ b/src/pipecat/processors/filters/stt_mute_filter.py @@ -234,12 +234,6 @@ class STTMuteFilter(FrameProcessor): await self.push_frame(frame, direction) else: logger.trace(f"{frame.__class__.__name__} suppressed - STT currently muted") - - # When muted, the InterruptionFrame won't propagate further - # and will never reach the pipeline sink. Complete it here so - # push_interruption_task_frame_and_wait() doesn't hang. - if isinstance(frame, InterruptionFrame): - frame.complete() else: # Pass all other frames through await self.push_frame(frame, direction) diff --git a/src/pipecat/processors/frame_processor.py b/src/pipecat/processors/frame_processor.py index 3e7b48442..69c503e71 100644 --- a/src/pipecat/processors/frame_processor.py +++ b/src/pipecat/processors/frame_processor.py @@ -41,7 +41,6 @@ from pipecat.frames.frames import ( FrameProcessorResumeFrame, FrameProcessorResumeUrgentFrame, InterruptionFrame, - InterruptionTaskFrame, StartFrame, SystemFrame, UninterruptibleFrame, @@ -240,10 +239,6 @@ class FrameProcessor(BaseObject): self.__process_frame_task: Optional[asyncio.Task] = None self.__process_current_frame: Optional[Frame] = None - # Set while awaiting push_interruption_task_frame_and_wait() so that - # _start_interruption() knows not to cancel the process task. - self._wait_for_interruption = False - # Frame processor events. self._register_event_handler("on_before_process_frame", sync=True) self._register_event_handler("on_after_process_frame", sync=True) @@ -329,7 +324,7 @@ class FrameProcessor(BaseObject): warnings.simplefilter("always") warnings.warn( "`FrameProcessor.interruptions_allowed` is deprecated. " - "Use `LLMUserAggregator`'s new `user_mute_strategies` parameter instead.", + "Use `LLMUserAggregator`'s new `user_mute_strategies` parameter instead.", DeprecationWarning, stacklevel=2, ) @@ -647,15 +642,6 @@ class FrameProcessor(BaseObject): if self._cancelling: return - # If we are waiting for an interruption, bypass all queued system frames - # and process the frame right away. This is because a previous system - # frame might be waiting for the interruption frame blocking the input - # task, so this InterruptionFrame would never be dequeued and we'd - # deadlock. - if self._wait_for_interruption and isinstance(frame, InterruptionFrame): - await self.__process_frame(frame, direction, callback) - return - if self._enable_direct_mode: await self.__process_frame(frame, direction, callback) else: @@ -790,43 +776,32 @@ class FrameProcessor(BaseObject): await self._call_event_handler("on_after_push_frame", frame) + async def broadcast_interruption(self): + """Broadcast an `InterruptionFrame` both upstream and downstream.""" + logger.debug(f"{self}: broadcasting interruption") + self.__reset_process_task() + await self.stop_all_metrics() + await self.broadcast_frame(InterruptionFrame) + async def push_interruption_task_frame_and_wait(self, *, timeout: float = 5.0): """Push an interruption task frame upstream and wait for the interruption. - This function sends an `InterruptionTaskFrame` upstream to the - pipeline task. The task creates a corresponding `InterruptionFrame` - and sends it downstream through the pipeline. An `asyncio.Event` is - attached to both frames so the caller can wait until the interruption - has fully traversed the pipeline. The event is set when the - `InterruptionFrame` reaches the pipeline sink. If the frame does - not complete within the given timeout, a warning is logged and the - event is forcibly set so the caller is unblocked. - - Args: - timeout: Maximum seconds to wait for the interruption to complete. + .. deprecated:: 0.0.104 + Use :meth:`broadcast_interruption` instead. This method now + delegates to ``broadcast_interruption()`` and ignores *timeout*. """ - self._wait_for_interruption = True + import warnings - event = asyncio.Event() + with warnings.catch_warnings(): + warnings.simplefilter("always") + warnings.warn( + "`FrameProcessor.push_interruption_task_frame_and_wait()` is deprecated. " + "Use `FrameProcessor.broadcast_interruption()` instead.", + DeprecationWarning, + stacklevel=2, + ) - await self.push_frame(InterruptionTaskFrame(event=event), FrameDirection.UPSTREAM) - - # Wait for the `InterruptionFrame` to complete and log a warning if it - # takes too long. If it does take too long make sure we unblock it, - # otherwise we will hang here forever. - while not event.is_set(): - try: - await asyncio.wait_for(event.wait(), timeout=timeout) - except asyncio.TimeoutError: - logger.warning( - f"{self}: InterruptionFrame has not completed after" - f" {timeout}s. Make sure InterruptionFrame.complete()" - " is being called (e.g. if the frame is being blocked" - " or consumed before reaching the pipeline sink)." - ) - event.set() - - self._wait_for_interruption = False + await self.broadcast_interruption() async def broadcast_frame(self, frame_cls: Type[Frame], **kwargs): """Broadcasts a frame of the specified class upstream and downstream. @@ -933,15 +908,7 @@ class FrameProcessor(BaseObject): async def _start_interruption(self): """Start handling an interruption by cancelling current tasks.""" try: - if self._wait_for_interruption: - # If we get here we know the process task was just waiting for - # an interruption (push_interruption_task_frame_and_wait()), so - # we can't cancel the task because it might still need to do - # more things (e.g. pushing a frame after the - # interruption). Instead we just drain the queue because this is - # an interruption. - self.__reset_process_task() - elif isinstance(self.__process_current_frame, UninterruptibleFrame): + if isinstance(self.__process_current_frame, UninterruptibleFrame): # We don't want to cancel UninterruptibleFrame, so we simply # cleanup the queue. self.__reset_process_queue() diff --git a/src/pipecat/processors/frameworks/rtvi.py b/src/pipecat/processors/frameworks/rtvi.py index e01e95714..eb1e79f3e 100644 --- a/src/pipecat/processors/frameworks/rtvi.py +++ b/src/pipecat/processors/frameworks/rtvi.py @@ -1702,7 +1702,7 @@ class RTVIProcessor(FrameProcessor): async def interrupt_bot(self): """Send a bot interruption frame upstream.""" - await self.push_interruption_task_frame_and_wait() + await self.broadcast_interruption() async def send_server_message(self, data: Any): """Send a server message to the client.""" diff --git a/src/pipecat/services/deepgram/flux/stt.py b/src/pipecat/services/deepgram/flux/stt.py index d509b267e..984906c6c 100644 --- a/src/pipecat/services/deepgram/flux/stt.py +++ b/src/pipecat/services/deepgram/flux/stt.py @@ -675,7 +675,7 @@ class DeepgramFluxSTTService(WebsocketSTTService): self._user_is_speaking = True await self.broadcast_frame(UserStartedSpeakingFrame) if self._should_interrupt: - await self.push_interruption_task_frame_and_wait() + await self.broadcast_interruption() await self.start_metrics() await self._call_event_handler("on_start_of_turn", transcript) if transcript: diff --git a/src/pipecat/services/deepgram/stt.py b/src/pipecat/services/deepgram/stt.py index 497d6aae1..8eb246cf2 100644 --- a/src/pipecat/services/deepgram/stt.py +++ b/src/pipecat/services/deepgram/stt.py @@ -471,7 +471,7 @@ class DeepgramSTTService(STTService): await self._call_event_handler("on_speech_started", *args, **kwargs) await self.broadcast_frame(UserStartedSpeakingFrame) if self._should_interrupt: - await self.push_interruption_task_frame_and_wait() + await self.broadcast_interruption() async def _on_utterance_end(self, *args, **kwargs): await self._call_event_handler("on_utterance_end", *args, **kwargs) diff --git a/src/pipecat/services/gladia/stt.py b/src/pipecat/services/gladia/stt.py index 045a56613..bba554b4a 100644 --- a/src/pipecat/services/gladia/stt.py +++ b/src/pipecat/services/gladia/stt.py @@ -613,7 +613,7 @@ class GladiaSTTService(WebsocketSTTService): await self.broadcast_frame(UserStartedSpeakingFrame) if self._should_interrupt: - await self.push_interruption_task_frame_and_wait() + await self.broadcast_interruption() async def _on_speech_ended(self): """Handle speech end event from Gladia. diff --git a/src/pipecat/services/google/gemini_live/llm.py b/src/pipecat/services/google/gemini_live/llm.py index d06f941c7..2ed11c739 100644 --- a/src/pipecat/services/google/gemini_live/llm.py +++ b/src/pipecat/services/google/gemini_live/llm.py @@ -1265,7 +1265,7 @@ class GeminiLiveLLMService(LLMService): # combination with the context aggregator default # turn strategies. logger.debug("Gemini VAD: interrupted signal received") - await self.push_interruption_task_frame_and_wait() + await self.broadcast_interruption() elif message.server_content and message.server_content.model_turn: await self._handle_msg_model_turn(message) elif ( diff --git a/src/pipecat/services/grok/realtime/llm.py b/src/pipecat/services/grok/realtime/llm.py index 6d148f6d7..7a4e73806 100644 --- a/src/pipecat/services/grok/realtime/llm.py +++ b/src/pipecat/services/grok/realtime/llm.py @@ -734,7 +734,7 @@ class GrokRealtimeLLMService(LLMService): """Handle speech started event from VAD.""" await self._truncate_current_audio_response() await self.broadcast_frame(UserStartedSpeakingFrame) - await self.push_interruption_task_frame_and_wait() + await self.broadcast_interruption() async def _handle_evt_speech_stopped(self, evt): """Handle speech stopped event from VAD.""" diff --git a/src/pipecat/services/openai/realtime/llm.py b/src/pipecat/services/openai/realtime/llm.py index a6667c7c8..07b6aa82b 100644 --- a/src/pipecat/services/openai/realtime/llm.py +++ b/src/pipecat/services/openai/realtime/llm.py @@ -839,7 +839,7 @@ class OpenAIRealtimeLLMService(LLMService): async def _handle_evt_speech_started(self, evt): await self._truncate_current_audio_response() await self.broadcast_frame(UserStartedSpeakingFrame) - await self.push_interruption_task_frame_and_wait() + await self.broadcast_interruption() async def _handle_evt_speech_stopped(self, evt): await self.start_ttfb_metrics() diff --git a/src/pipecat/services/openai/stt.py b/src/pipecat/services/openai/stt.py index 9a52be114..32895f8b5 100644 --- a/src/pipecat/services/openai/stt.py +++ b/src/pipecat/services/openai/stt.py @@ -639,7 +639,7 @@ class OpenAIRealtimeSTTService(WebsocketSTTService): logger.debug("Server VAD: speech started") await self.broadcast_frame(UserStartedSpeakingFrame) if self._should_interrupt: - await self.push_interruption_task_frame_and_wait() + await self.broadcast_interruption() await self.start_processing_metrics() async def _handle_speech_stopped(self, evt: dict): diff --git a/src/pipecat/services/openai_realtime_beta/openai.py b/src/pipecat/services/openai_realtime_beta/openai.py index 8614713ff..c912ed45c 100644 --- a/src/pipecat/services/openai_realtime_beta/openai.py +++ b/src/pipecat/services/openai_realtime_beta/openai.py @@ -709,7 +709,7 @@ class OpenAIRealtimeBetaLLMService(LLMService): async def _handle_evt_speech_started(self, evt): await self._truncate_current_audio_response() await self.broadcast_frame(UserStartedSpeakingFrame) - await self.push_interruption_task_frame_and_wait() + await self.broadcast_interruption() async def _handle_evt_speech_stopped(self, evt): await self.start_ttfb_metrics() diff --git a/src/pipecat/services/sarvam/stt.py b/src/pipecat/services/sarvam/stt.py index 9e245aece..e368ceb02 100644 --- a/src/pipecat/services/sarvam/stt.py +++ b/src/pipecat/services/sarvam/stt.py @@ -644,7 +644,7 @@ class SarvamSTTService(STTService): logger.debug("User started speaking") await self._call_event_handler("on_speech_started") await self.broadcast_frame(UserStartedSpeakingFrame) - await self.push_interruption_task_frame_and_wait() + await self.broadcast_interruption() elif signal == "END_SPEECH": logger.debug("User stopped speaking") diff --git a/src/pipecat/services/speechmatics/stt.py b/src/pipecat/services/speechmatics/stt.py index ac18a36e3..bdeb3b249 100644 --- a/src/pipecat/services/speechmatics/stt.py +++ b/src/pipecat/services/speechmatics/stt.py @@ -836,7 +836,7 @@ class SpeechmaticsSTTService(STTService): # await self.start_processing_metrics() await self.broadcast_frame(UserStartedSpeakingFrame) if self._should_interrupt: - await self.push_interruption_task_frame_and_wait() + await self.broadcast_interruption() async def _handle_end_of_turn(self, message: dict[str, Any]) -> None: """Handle EndOfTurn events. diff --git a/src/pipecat/transports/base_input.py b/src/pipecat/transports/base_input.py index 49c28149a..1da672ab7 100644 --- a/src/pipecat/transports/base_input.py +++ b/src/pipecat/transports/base_input.py @@ -558,7 +558,7 @@ class BaseInputTransport(FrameProcessor): # Make sure we notify about interruptions quickly out-of-band. if should_push_immediate_interruption and self._allow_interruptions: - await self.push_interruption_task_frame_and_wait() + await self.broadcast_interruption() elif self.interruption_strategies and self._bot_speaking: logger.debug( "User started speaking while bot is speaking with interruption config - " diff --git a/src/pipecat/turns/user_turn_processor.py b/src/pipecat/turns/user_turn_processor.py index 7f8995202..85bc658dd 100644 --- a/src/pipecat/turns/user_turn_processor.py +++ b/src/pipecat/turns/user_turn_processor.py @@ -182,7 +182,7 @@ class UserTurnProcessor(FrameProcessor): await self._user_idle_controller.process_frame(UserStartedSpeakingFrame()) if params.enable_interruptions and self._allow_interruptions: - await self.push_interruption_task_frame_and_wait() + await self.broadcast_interruption() await self._call_event_handler("on_user_turn_started", strategy) diff --git a/tests/test_context_aggregators.py b/tests/test_context_aggregators.py index 24dae0b4c..37d36bfef 100644 --- a/tests/test_context_aggregators.py +++ b/tests/test_context_aggregators.py @@ -21,7 +21,6 @@ from pipecat.frames.frames import ( FunctionCallResultProperties, InterimTranscriptionFrame, InterruptionFrame, - InterruptionTaskFrame, LLMContextAssistantTimestampFrame, LLMContextFrame, LLMFullResponseEndFrame, @@ -567,7 +566,7 @@ class BaseTestUserContextAggregator: SleepFrame(), UserStoppedSpeakingFrame(), ] - expected_up_frames = [InterruptionTaskFrame] + expected_up_frames = [InterruptionFrame] expected_down_frames = [ BotStartedSpeakingFrame, UserStartedSpeakingFrame, diff --git a/tests/test_frame_processor.py b/tests/test_frame_processor.py index 138c8e6d8..a875741e3 100644 --- a/tests/test_frame_processor.py +++ b/tests/test_frame_processor.py @@ -9,8 +9,6 @@ import unittest from dataclasses import dataclass, field from typing import List -from loguru import logger - from pipecat.frames.frames import ( DataFrame, EndFrame, @@ -85,50 +83,38 @@ class TestFrameProcessor(unittest.IsolatedAsyncioTestCase): assert before_push_called assert after_push_called - async def test_interruption_and_wait(self): - class DelayFrameProcessor(FrameProcessor): - """This processors just gives time to the event loop to change - between tasks. Otherwise things happen to fast.""" - - async def process_frame(self, frame: Frame, direction: FrameDirection): - await super().process_frame(frame, direction) - await asyncio.sleep(0.1) - await self.push_frame(frame, direction) + async def test_broadcast_interruption(self): + """Test that broadcast_interruption() pushes InterruptionFrame both + directions and allows subsequent code to run.""" class InterruptFrameProcessor(FrameProcessor): async def process_frame(self, frame: Frame, direction: FrameDirection): await super().process_frame(frame, direction) if isinstance(frame, TextFrame): - await self.push_interruption_task_frame_and_wait() + await self.broadcast_interruption() await self.push_frame(OutputTransportMessageUrgentFrame(message=frame.text)) else: await self.push_frame(frame, direction) - pipeline = Pipeline([DelayFrameProcessor(), InterruptFrameProcessor()]) + pipeline = Pipeline([InterruptFrameProcessor()]) frames_to_send = [ - # Just a random interruption to make sure we don't clear anything - # before the actual `InterruptionTaskFrame` interruption. - InterruptionFrame(), - # This will generate an `InterruptionTaskFrame` and will wait for an - # `InterruptionFrame`. TextFrame(text="Hello from Pipecat!"), - # Just give time for everything to complete. SleepFrame(sleep=0.5), - EndFrame(), ] expected_down_frames = [ - InterruptionFrame, InterruptionFrame, OutputTransportMessageUrgentFrame, - EndFrame, + ] + expected_up_frames = [ + InterruptionFrame, ] await run_test( pipeline, frames_to_send=frames_to_send, expected_down_frames=expected_down_frames, - send_end_frame=False, + expected_up_frames=expected_up_frames, ) async def test_interruptible_frames(self): @@ -454,33 +440,20 @@ class TestFrameProcessor(unittest.IsolatedAsyncioTestCase): stop_frames = [f for f in received_frames if isinstance(f, StopFrame)] self.assertEqual(len(stop_frames), 1, "StopFrame should survive interruption") - async def test_interruption_frame_complete_sets_event(self): - """Test that InterruptionFrame.complete() sets the event.""" - event = asyncio.Event() - frame = InterruptionFrame(event=event) - self.assertFalse(event.is_set()) - frame.complete() - self.assertTrue(event.is_set()) - - async def test_interruption_frame_complete_without_event(self): - """Test that InterruptionFrame.complete() is safe without an event.""" - frame = InterruptionFrame() - frame.complete() # Should not raise - - async def test_interruption_event_set_at_pipeline_sink(self): - """Test that the event from push_interruption_task_frame_and_wait() - is set when the InterruptionFrame reaches the pipeline sink.""" - event_was_set = False + async def test_broadcast_interruption_allows_subsequent_code(self): + """Test that broadcast_interruption() returns immediately, allowing the + caller to run code afterwards (e.g. push an urgent frame).""" + code_after_ran = False class InterruptOnTextProcessor(FrameProcessor): async def process_frame(self, frame: Frame, direction: FrameDirection): - nonlocal event_was_set + nonlocal code_after_ran await super().process_frame(frame, direction) if isinstance(frame, TextFrame): - await self.push_interruption_task_frame_and_wait() + await self.broadcast_interruption() - event_was_set = True + code_after_ran = True await self.push_frame(OutputTransportMessageUrgentFrame(message="done")) else: await self.push_frame(frame, direction) @@ -499,63 +472,7 @@ class TestFrameProcessor(unittest.IsolatedAsyncioTestCase): frames_to_send=frames_to_send, expected_down_frames=expected_down_frames, ) - self.assertTrue(event_was_set, "Event should be set after InterruptionFrame completes") - - async def test_interruption_completion_timeout_warning(self): - """Test that a warning is logged when an InterruptionFrame is blocked - and never reaches the pipeline sink.""" - warnings = [] - handler_id = logger.add( - lambda msg: warnings.append(str(msg)), level="WARNING", format="{message}" - ) - - try: - - class BlockInterruptionProcessor(FrameProcessor): - """Blocks InterruptionFrames, completing them after a delay.""" - - async def process_frame(self, frame: Frame, direction: FrameDirection): - await super().process_frame(frame, direction) - if isinstance(frame, InterruptionFrame): - # Complete after the timeout so the warning fires - # but the test doesn't hang. - async def delayed_complete(): - await asyncio.sleep(1.0) - frame.complete() - - asyncio.create_task(delayed_complete()) - return - await self.push_frame(frame, direction) - - class InterruptOnTextProcessor(FrameProcessor): - async def process_frame(self, frame: Frame, direction: FrameDirection): - await super().process_frame(frame, direction) - if isinstance(frame, TextFrame): - await self.push_interruption_task_frame_and_wait(timeout=0.5) - await self.push_frame(OutputTransportMessageUrgentFrame(message="done")) - else: - await self.push_frame(frame, direction) - - pipeline = Pipeline([BlockInterruptionProcessor(), InterruptOnTextProcessor()]) - - frames_to_send = [ - TextFrame(text="trigger"), - ] - expected_down_frames = [ - OutputTransportMessageUrgentFrame, - ] - await run_test( - pipeline, - frames_to_send=frames_to_send, - expected_down_frames=expected_down_frames, - ) - finally: - logger.remove(handler_id) - - self.assertTrue( - any("InterruptionFrame has not completed" in w for w in warnings), - "Expected a timeout warning about InterruptionFrame not completing", - ) + self.assertTrue(code_after_ran, "Code after broadcast_interruption() should execute") if __name__ == "__main__": diff --git a/tests/test_stt_mute_filter.py b/tests/test_stt_mute_filter.py index adf4611df..8f55bdecb 100644 --- a/tests/test_stt_mute_filter.py +++ b/tests/test_stt_mute_filter.py @@ -4,7 +4,6 @@ # SPDX-License-Identifier: BSD 2-Clause License # -import asyncio import unittest from pipecat.frames.frames import ( @@ -329,17 +328,13 @@ class TestSTTMuteFilter(unittest.IsolatedAsyncioTestCase): expected_down_frames=expected_returned_frames, ) - async def test_interruption_frame_completed_when_muted(self): - """Test that InterruptionFrame.complete() is called when the frame is - suppressed due to muting, so push_interruption_task_frame_and_wait() - doesn't hang.""" + async def test_interruption_frame_suppressed_when_muted(self): + """Test that InterruptionFrame is suppressed when the filter is muted.""" filter = STTMuteFilter(config=STTMuteConfig(strategies={STTMuteStrategy.ALWAYS})) - event = asyncio.Event() - frames_to_send = [ BotStartedSpeakingFrame(), - InterruptionFrame(event=event), + InterruptionFrame(), BotStoppedSpeakingFrame(), ] @@ -354,8 +349,6 @@ class TestSTTMuteFilter(unittest.IsolatedAsyncioTestCase): expected_down_frames=expected_returned_frames, ) - self.assertTrue(event.is_set(), "InterruptionFrame.complete() should be called when muted") - if __name__ == "__main__": unittest.main() From 741ff14d3a552a2530aaa2c01afe94c7a3b893df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Mon, 2 Mar 2026 12:06:08 -0800 Subject: [PATCH 47/68] Rename changelog files to use PR #3896 and mark breaking change --- changelog/{3900.added.md => 3896.added.md} | 0 changelog/3896.changed.md | 1 + changelog/{3900.deprecated.md => 3896.deprecated.md} | 0 changelog/3900.changed.md | 1 - 4 files changed, 1 insertion(+), 1 deletion(-) rename changelog/{3900.added.md => 3896.added.md} (100%) create mode 100644 changelog/3896.changed.md rename changelog/{3900.deprecated.md => 3896.deprecated.md} (100%) delete mode 100644 changelog/3900.changed.md diff --git a/changelog/3900.added.md b/changelog/3896.added.md similarity index 100% rename from changelog/3900.added.md rename to changelog/3896.added.md diff --git a/changelog/3896.changed.md b/changelog/3896.changed.md new file mode 100644 index 000000000..3b7e4f807 --- /dev/null +++ b/changelog/3896.changed.md @@ -0,0 +1 @@ +- ⚠️ Removed `event` field and `complete()` method from `InterruptionFrame`. Removed `event` field from `InterruptionTaskFrame`. These are no longer needed since `broadcast_interruption()` does not require a round-trip completion signal. diff --git a/changelog/3900.deprecated.md b/changelog/3896.deprecated.md similarity index 100% rename from changelog/3900.deprecated.md rename to changelog/3896.deprecated.md diff --git a/changelog/3900.changed.md b/changelog/3900.changed.md deleted file mode 100644 index 59b4cdb95..000000000 --- a/changelog/3900.changed.md +++ /dev/null @@ -1 +0,0 @@ -- Removed `event` field and `complete()` method from `InterruptionFrame`. Removed `event` field from `InterruptionTaskFrame`. These are no longer needed since `broadcast_interruption()` does not require a round-trip completion signal. From 07fdd610ca3f5e82d686ecf37abb29a59c433480 Mon Sep 17 00:00:00 2001 From: filipi87 Date: Mon, 2 Mar 2026 19:02:33 -0300 Subject: [PATCH 48/68] Using a default voice in case it is not provided. --- src/pipecat/transports/lemonslice/utils.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/pipecat/transports/lemonslice/utils.py b/src/pipecat/transports/lemonslice/utils.py index 98aac3ccb..cac341d7d 100644 --- a/src/pipecat/transports/lemonslice/utils.py +++ b/src/pipecat/transports/lemonslice/utils.py @@ -64,7 +64,9 @@ class LemonSliceApi: ValueError: If neither agent_id nor agent_image_url is provided. """ if not agent_id and not agent_image_url: - raise ValueError("Provide either agent_id or agent_image_url") + # Fallback to a default agent if none is provided + logger.debug("No agent_id or agent_image_url provided, using default agent") + agent_id = "agent_080308d8b6e99f47" if agent_id and agent_image_url: raise ValueError("Provide exactly one of agent_id or agent_image_url, not both") From 7afd7068b5b57949c5db2d600b34b21714957cbb Mon Sep 17 00:00:00 2001 From: filipi87 Date: Mon, 2 Mar 2026 19:02:51 -0300 Subject: [PATCH 49/68] Retrieving the elevenlabs voice ID from environment variable --- examples/foundational/55-lemonslice-transport.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/foundational/55-lemonslice-transport.py b/examples/foundational/55-lemonslice-transport.py index c9f080567..a8e22ede1 100644 --- a/examples/foundational/55-lemonslice-transport.py +++ b/examples/foundational/55-lemonslice-transport.py @@ -53,7 +53,7 @@ async def main(): tts = ElevenLabsTTSService( api_key=os.getenv("ELEVENLABS_API_KEY", ""), - voice_id="ys3XeJJA4ArWMhRpcX1D", + voice_id=os.getenv("ELEVENLABS_VOICE_ID", ""), ) messages = [ From 7648b62e6e50639d4f6607da75fe69e1d4132252 Mon Sep 17 00:00:00 2001 From: zkleb-aai <146127913+zkleb-aai@users.noreply.github.com> Date: Mon, 2 Mar 2026 17:04:17 -0500 Subject: [PATCH 50/68] Update src/pipecat/services/assemblyai/stt.py Co-authored-by: Mark Backman --- src/pipecat/services/assemblyai/stt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pipecat/services/assemblyai/stt.py b/src/pipecat/services/assemblyai/stt.py index 9938afdee..8ef6b5a07 100644 --- a/src/pipecat/services/assemblyai/stt.py +++ b/src/pipecat/services/assemblyai/stt.py @@ -590,7 +590,7 @@ class AssemblyAISTTService(WebsocketSTTService): data = json.loads(message) # Log raw JSON for Turn messages to debug speaker_label if data.get("type") == "Turn": - logger.debug(f"{self} RAW JSON from AssemblyAI: {json.dumps(data, indent=2)}") + logger.trace(f"{self} RAW JSON from AssemblyAI: {json.dumps(data, indent=2)}") await self._handle_message(data) except json.JSONDecodeError: logger.warning(f"Received non-JSON message: {message}") From 6729f4366a68bb56f4b8a11f22b15b1c4ce4a333 Mon Sep 17 00:00:00 2001 From: zkleb-aai <146127913+zkleb-aai@users.noreply.github.com> Date: Mon, 2 Mar 2026 17:04:42 -0500 Subject: [PATCH 51/68] Update src/pipecat/services/assemblyai/stt.py Co-authored-by: Mark Backman --- src/pipecat/services/assemblyai/stt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pipecat/services/assemblyai/stt.py b/src/pipecat/services/assemblyai/stt.py index 8ef6b5a07..bbbdad3f8 100644 --- a/src/pipecat/services/assemblyai/stt.py +++ b/src/pipecat/services/assemblyai/stt.py @@ -717,7 +717,7 @@ class AssemblyAISTTService(WebsocketSTTService): finalize_confirmed = bool(message.turn_is_formatted) if finalize_confirmed: self.confirm_finalize() - logger.debug(f'{self} Final transcript: "{transcript_text}"') + logger.debug(f'{self} Transcript: "{transcript_text}"') await self.push_frame( TranscriptionFrame( transcript_text, From 5c2ca0ce64d41f096bcfb7569dbda6480da31c13 Mon Sep 17 00:00:00 2001 From: zkleb-aai <146127913+zkleb-aai@users.noreply.github.com> Date: Mon, 2 Mar 2026 17:04:54 -0500 Subject: [PATCH 52/68] Update changelog/3856.changed.md Co-authored-by: Mark Backman --- changelog/3856.changed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/3856.changed.md b/changelog/3856.changed.md index 72331c068..1e7f4c916 100644 --- a/changelog/3856.changed.md +++ b/changelog/3856.changed.md @@ -1 +1 @@ -- Rename AssemblyAI min_end_of_turn_silence_when_confident parameter to min_turn_silence (old name still supported with deprecation warning) +- Rename `AssemblyAISTTService` parameter `min_end_of_turn_silence_when_confident` parameter to `min_turn_silence` (old name still supported with deprecation warning) From ebb794995b6133ec79639356a8eebf6b31b745b5 Mon Sep 17 00:00:00 2001 From: filipi87 Date: Mon, 2 Mar 2026 19:06:13 -0300 Subject: [PATCH 53/68] Changing the log levels. --- src/pipecat/transports/lemonslice/transport.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/pipecat/transports/lemonslice/transport.py b/src/pipecat/transports/lemonslice/transport.py index 9ef014f79..384921d62 100644 --- a/src/pipecat/transports/lemonslice/transport.py +++ b/src/pipecat/transports/lemonslice/transport.py @@ -329,7 +329,7 @@ class LemonSliceTransportClient: async def send_response_started_message(self) -> None: """Send a response_started message to the LemonSlice session.""" - logger.info("Sending response_started message") + logger.trace("Sending response_started message") transport_frame = OutputTransportMessageUrgentFrame( message={ "event": "response_started", @@ -340,7 +340,7 @@ class LemonSliceTransportClient: async def send_response_finished_message(self) -> None: """Send a response_finished message to the LemonSlice session.""" - logger.debug("Sending response_finished message") + logger.trace("Sending response_finished message") transport_frame = OutputTransportMessageUrgentFrame( message={ "event": "response_finished", @@ -470,7 +470,7 @@ class LemonSliceInputTransport(BaseInputTransport): participant: The participant to capture audio from. """ if self._params.audio_in_enabled: - logger.info( + logger.debug( f"LemonSliceTransportClient start capturing audio for participant {participant['id']}" ) await self._client.capture_participant_audio( @@ -587,7 +587,7 @@ class LemonSliceOutputTransport(BaseOutputTransport): Args: frame: The message frame to send. """ - logger.info(f"LemonSliceTransport sending message {frame}") + logger.trace(f"LemonSliceTransport sending message {frame}") await self._client.send_message(frame) async def push_frame(self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM): From daf14f50656b9de046ded01c7f5ab27d9d8dbbd0 Mon Sep 17 00:00:00 2001 From: filipi87 Date: Mon, 2 Mar 2026 19:08:17 -0300 Subject: [PATCH 54/68] Renaming LemonSlice utils file to api. --- src/pipecat/transports/lemonslice/{utils.py => api.py} | 0 src/pipecat/transports/lemonslice/transport.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename src/pipecat/transports/lemonslice/{utils.py => api.py} (100%) diff --git a/src/pipecat/transports/lemonslice/utils.py b/src/pipecat/transports/lemonslice/api.py similarity index 100% rename from src/pipecat/transports/lemonslice/utils.py rename to src/pipecat/transports/lemonslice/api.py diff --git a/src/pipecat/transports/lemonslice/transport.py b/src/pipecat/transports/lemonslice/transport.py index 384921d62..d649a3fcb 100644 --- a/src/pipecat/transports/lemonslice/transport.py +++ b/src/pipecat/transports/lemonslice/transport.py @@ -40,7 +40,7 @@ from pipecat.transports.daily.transport import ( DailyParams, DailyTransportClient, ) -from pipecat.transports.lemonslice.utils import LemonSliceApi +from pipecat.transports.lemonslice.api import LemonSliceApi class LemonSliceCallbacks(BaseModel): From f07e55a4ed8b42b918ca62d12452d25ddebc0959 Mon Sep 17 00:00:00 2001 From: filipi87 Date: Mon, 2 Mar 2026 19:15:18 -0300 Subject: [PATCH 55/68] Wrap LemonSlice session creation params in LemonSliceNewSessionRequest --- .../foundational/55-lemonslice-transport.py | 10 ++- .../transports/lemonslice/transport.py | 87 ++++++++----------- 2 files changed, 46 insertions(+), 51 deletions(-) diff --git a/examples/foundational/55-lemonslice-transport.py b/examples/foundational/55-lemonslice-transport.py index a8e22ede1..8b2e19a6e 100644 --- a/examples/foundational/55-lemonslice-transport.py +++ b/examples/foundational/55-lemonslice-transport.py @@ -25,7 +25,11 @@ from pipecat.processors.aggregators.llm_response_universal import ( from pipecat.services.deepgram.stt import DeepgramSTTService from pipecat.services.elevenlabs.tts import ElevenLabsTTSService from pipecat.services.groq.llm import GroqLLMService -from pipecat.transports.lemonslice.transport import LemonSliceParams, LemonSliceTransport +from pipecat.transports.lemonslice.transport import ( + LemonSliceNewSessionRequest, + LemonSliceParams, + LemonSliceTransport, +) load_dotenv(override=True) @@ -38,8 +42,10 @@ async def main(): transport = LemonSliceTransport( bot_name="Pipecat", api_key=os.getenv("LEMONSLICE_API_KEY"), - agent_id=os.getenv("LEMONSLICE_AGENT_ID"), session=session, + session_request=LemonSliceNewSessionRequest( + agent_id=os.getenv("LEMONSLICE_AGENT_ID"), + ), params=LemonSliceParams( audio_in_enabled=True, audio_out_enabled=True, diff --git a/src/pipecat/transports/lemonslice/transport.py b/src/pipecat/transports/lemonslice/transport.py index d649a3fcb..6a6894167 100644 --- a/src/pipecat/transports/lemonslice/transport.py +++ b/src/pipecat/transports/lemonslice/transport.py @@ -43,6 +43,29 @@ from pipecat.transports.daily.transport import ( from pipecat.transports.lemonslice.api import LemonSliceApi +class LemonSliceNewSessionRequest(BaseModel): + """Request model for creating a new LemonSlice session. + + Parameters: + agent_image_url: URL to an agent image. Provide either agent_id or agent_image_url. + agent_id: ID of a LemonSlice agent. Provide either agent_id or agent_image_url. + agent_prompt: A high-level system prompt that subtly influences the avatar's movements, + expressions, and emotional demeanor. + idle_timeout: Idle timeout in seconds. + daily_room_url: Daily room URL to use for the session. + daily_token: Daily token for authenticating with the room. + lemonslice_properties: Additional properties to pass to the session. + """ + + agent_image_url: Optional[str] = None + agent_id: Optional[str] = None + agent_prompt: Optional[str] = None + idle_timeout: Optional[int] = None + daily_room_url: Optional[str] = None + daily_token: Optional[str] = None + lemonslice_properties: Optional[dict] = None + + class LemonSliceCallbacks(BaseModel): """Callback handlers for LemonSlice events. @@ -87,13 +110,7 @@ class LemonSliceTransportClient: params: LemonSliceParams = LemonSliceParams(), callbacks: LemonSliceCallbacks, api_key: str, - agent_image_url: Optional[str] = None, - agent_id: Optional[str] = None, - agent_prompt: Optional[str] = None, - idle_timeout: Optional[int] = None, - daily_room_url: Optional[str] = None, - daily_token: Optional[str] = None, - lemonslice_properties: Optional[dict] = None, + session_request: Optional[LemonSliceNewSessionRequest] = None, session: aiohttp.ClientSession, ) -> None: """Initialize the LemonSlice transport client. @@ -103,24 +120,13 @@ class LemonSliceTransportClient: params: Optional parameters for LemonSlice operation. callbacks: Callback handlers for LemonSlice-related events. api_key: API key for authenticating with LemonSlice API. - agent_image_url: Optional URL to an agent image. - agent_id: Optional ID of LemonSlice agent. - agent_prompt: Optional system prompt for the avatar. - idle_timeout: Optional idle timeout in seconds. - daily_room_url: Optional Daily room URL to add the LemonSlice avatar to. - daily_token: Optional Daily token for authenticating with the room. - lemonslice_properties: Optional additional properties for the session. + session_request: Optional session creation parameters. If not provided, a default + agent will be used. session: The aiohttp session for making async HTTP requests. """ self._bot_name = bot_name self._api = LemonSliceApi(api_key, session) - self._agent_id = agent_id - self._agent_image_url = agent_image_url - self._agent_prompt = agent_prompt - self._idle_timeout = idle_timeout - self._daily_room_url = daily_room_url - self._daily_token = daily_token - self._lemonslice_properties = lemonslice_properties + self._session_request = session_request or LemonSliceNewSessionRequest() self._session_id: Optional[str] = None self._control_url: Optional[str] = None self._daily_transport_client: Optional[DailyTransportClient] = None @@ -130,13 +136,13 @@ class LemonSliceTransportClient: async def _initialize(self) -> str: """Initialize the conversation and return the room URL.""" response = await self._api.create_session( - agent_image_url=self._agent_image_url, - agent_id=self._agent_id, - agent_prompt=self._agent_prompt, - idle_timeout=self._idle_timeout, - daily_room_url=self._daily_room_url, - daily_token=self._daily_token, - properties=self._lemonslice_properties, + agent_image_url=self._session_request.agent_image_url, + agent_id=self._session_request.agent_id, + agent_prompt=self._session_request.agent_prompt, + idle_timeout=self._session_request.idle_timeout, + daily_room_url=self._session_request.daily_room_url, + daily_token=self._session_request.daily_token, + properties=self._session_request.lemonslice_properties, ) self._session_id = response["session_id"] self._control_url = response["control_url"] @@ -675,16 +681,10 @@ class LemonSliceTransport(BaseTransport): bot_name: str, session: aiohttp.ClientSession, api_key: str, - agent_image_url: Optional[str] = None, - agent_id: Optional[str] = None, - agent_prompt: Optional[str] = None, - idle_timeout: Optional[int] = None, + session_request: Optional[LemonSliceNewSessionRequest] = None, params: LemonSliceParams = LemonSliceParams(), input_name: Optional[str] = None, output_name: Optional[str] = None, - daily_room_url: Optional[str] = None, - daily_token: Optional[str] = None, - lemonslice_properties: Optional[dict] = None, ): """Initialize the LemonSlice transport. @@ -692,16 +692,11 @@ class LemonSliceTransport(BaseTransport): bot_name: The name of the Pipecat bot. session: aiohttp session used for async HTTP requests. api_key: LemonSlice API key for authentication. - agent_image_url: Optional URL to an agent image. - agent_id: Optional ID of the LemonSlice agent. - agent_prompt: Optional system prompt for the avatar. - idle_timeout: Optional idle timeout in seconds. + session_request: Optional session creation parameters. If not provided, a default + agent will be used. params: Optional LemonSlice-specific configuration parameters. input_name: Optional name for the input transport. output_name: Optional name for the output transport. - daily_room_url: Optional Daily room URL to add the LemonSlice avatar to. - daily_token: Optional Daily token for authenticating with the room. - lemonslice_properties: Optional additional properties for the session. """ super().__init__(input_name=input_name, output_name=output_name) self._params = params @@ -714,13 +709,7 @@ class LemonSliceTransport(BaseTransport): bot_name=bot_name, callbacks=callbacks, api_key=api_key, - agent_image_url=agent_image_url, - agent_id=agent_id, - agent_prompt=agent_prompt, - idle_timeout=idle_timeout, - daily_room_url=daily_room_url, - daily_token=daily_token, - lemonslice_properties=lemonslice_properties, + session_request=session_request, session=session, params=params, ) From 11783520c05d20bd7a1e2d8100bf16f6bddaa7ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Mon, 2 Mar 2026 14:43:34 -0800 Subject: [PATCH 56/68] services(deepgram): move stt|tts_sagemaker to sagemaker/stt|tts.py --- src/pipecat/services/deepgram/__init__.py | 1 + .../services/deepgram/{stt_sagemaker.py => sagemaker/stt.py} | 0 .../services/deepgram/{tts_sagemaker.py => sagemaker/tts.py} | 0 3 files changed, 1 insertion(+) rename src/pipecat/services/deepgram/{stt_sagemaker.py => sagemaker/stt.py} (100%) rename src/pipecat/services/deepgram/{tts_sagemaker.py => sagemaker/tts.py} (100%) diff --git a/src/pipecat/services/deepgram/__init__.py b/src/pipecat/services/deepgram/__init__.py index 4e1db3886..f67271abc 100644 --- a/src/pipecat/services/deepgram/__init__.py +++ b/src/pipecat/services/deepgram/__init__.py @@ -9,6 +9,7 @@ import sys from pipecat.services import DeprecatedModuleProxy from .flux import * +from .sagemaker import * from .stt import * from .tts import * diff --git a/src/pipecat/services/deepgram/stt_sagemaker.py b/src/pipecat/services/deepgram/sagemaker/stt.py similarity index 100% rename from src/pipecat/services/deepgram/stt_sagemaker.py rename to src/pipecat/services/deepgram/sagemaker/stt.py diff --git a/src/pipecat/services/deepgram/tts_sagemaker.py b/src/pipecat/services/deepgram/sagemaker/tts.py similarity index 100% rename from src/pipecat/services/deepgram/tts_sagemaker.py rename to src/pipecat/services/deepgram/sagemaker/tts.py From fdeddd7c95d38c3f4f77ccd2cdaa86afd67568ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Mon, 2 Mar 2026 14:47:17 -0800 Subject: [PATCH 57/68] Add deprecation shims for moved stt_sagemaker/tts_sagemaker modules Re-export from the new pipecat.services.deepgram.sagemaker.{stt,tts} paths so existing imports keep working with a deprecation warning. --- src/pipecat/services/deepgram/stt_sagemaker.py | 18 ++++++++++++++++++ src/pipecat/services/deepgram/tts_sagemaker.py | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 src/pipecat/services/deepgram/stt_sagemaker.py create mode 100644 src/pipecat/services/deepgram/tts_sagemaker.py diff --git a/src/pipecat/services/deepgram/stt_sagemaker.py b/src/pipecat/services/deepgram/stt_sagemaker.py new file mode 100644 index 000000000..08cd0c5d3 --- /dev/null +++ b/src/pipecat/services/deepgram/stt_sagemaker.py @@ -0,0 +1,18 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Deprecated: use ``pipecat.services.deepgram.sagemaker.stt`` instead.""" + +import warnings + +warnings.warn( + "Module `pipecat.services.deepgram.stt_sagemaker` is deprecated, " + "use `pipecat.services.deepgram.sagemaker.stt` instead.", + DeprecationWarning, + stacklevel=2, +) + +from pipecat.services.deepgram.sagemaker.stt import * # noqa: E402, F401, F403 diff --git a/src/pipecat/services/deepgram/tts_sagemaker.py b/src/pipecat/services/deepgram/tts_sagemaker.py new file mode 100644 index 000000000..61ca2bceb --- /dev/null +++ b/src/pipecat/services/deepgram/tts_sagemaker.py @@ -0,0 +1,18 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Deprecated: use ``pipecat.services.deepgram.sagemaker.tts`` instead.""" + +import warnings + +warnings.warn( + "Module `pipecat.services.deepgram.tts_sagemaker` is deprecated, " + "use `pipecat.services.deepgram.sagemaker.tts` instead.", + DeprecationWarning, + stacklevel=2, +) + +from pipecat.services.deepgram.sagemaker.tts import * # noqa: E402, F401, F403 From aae9136df913c5fdfb2d4ee252411fe09b8ac001 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Mon, 2 Mar 2026 17:44:35 -0500 Subject: [PATCH 58/68] Review feedback --- README.md | 2 +- ...slice-transport.py => 56-lemonslice-transport.py} | 0 examples/foundational/README.md | 2 +- uv.lock | 12 +++++++++++- 4 files changed, 13 insertions(+), 3 deletions(-) rename examples/foundational/{55-lemonslice-transport.py => 56-lemonslice-transport.py} (100%) diff --git a/README.md b/README.md index 6a8c8c1fa..881b0ed5e 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ Catch new features, interviews, and how-tos on our [Pipecat TV](https://www.yout | Speech-to-Speech | [AWS Nova Sonic](https://docs.pipecat.ai/server/services/s2s/aws), [Gemini Multimodal Live](https://docs.pipecat.ai/server/services/s2s/gemini), [Grok Voice Agent](https://docs.pipecat.ai/server/services/s2s/grok), [OpenAI Realtime](https://docs.pipecat.ai/server/services/s2s/openai), [Ultravox](https://docs.pipecat.ai/server/services/s2s/ultravox), | | Transport | [Daily (WebRTC)](https://docs.pipecat.ai/server/services/transport/daily), [FastAPI Websocket](https://docs.pipecat.ai/server/services/transport/fastapi-websocket), [SmallWebRTCTransport](https://docs.pipecat.ai/server/services/transport/small-webrtc), [WebSocket Server](https://docs.pipecat.ai/server/services/transport/websocket-server), Local | | Serializers | [Exotel](https://docs.pipecat.ai/server/utilities/serializers/exotel), [Plivo](https://docs.pipecat.ai/server/utilities/serializers/plivo), [Twilio](https://docs.pipecat.ai/server/utilities/serializers/twilio), [Telnyx](https://docs.pipecat.ai/server/utilities/serializers/telnyx), [Vonage](https://docs.pipecat.ai/server/utilities/serializers/vonage) | -| Video | [HeyGen](https://docs.pipecat.ai/server/services/video/heygen), [LemonSlice](https://lemonslice.com/docs/self-managed/overview), [Tavus](https://docs.pipecat.ai/server/services/video/tavus), [Simli](https://docs.pipecat.ai/server/services/video/simli) | +| Video | [HeyGen](https://docs.pipecat.ai/server/services/video/heygen), [LemonSlice](https://docs.pipecat.ai/server/services/video/lemonslice), [Tavus](https://docs.pipecat.ai/server/services/video/tavus), [Simli](https://docs.pipecat.ai/server/services/video/simli) | | Memory | [mem0](https://docs.pipecat.ai/server/services/memory/mem0) | | Vision & Image | [fal](https://docs.pipecat.ai/server/services/image-generation/fal), [Google Imagen](https://docs.pipecat.ai/server/services/image-generation/google-imagen), [Moondream](https://docs.pipecat.ai/server/services/vision/moondream) | | Audio Processing | [Silero VAD](https://docs.pipecat.ai/server/utilities/audio/silero-vad-analyzer), [Krisp](https://docs.pipecat.ai/server/utilities/audio/krisp-filter), [Koala](https://docs.pipecat.ai/server/utilities/audio/koala-filter), [ai-coustics](https://docs.pipecat.ai/server/utilities/audio/aic-filter) | diff --git a/examples/foundational/55-lemonslice-transport.py b/examples/foundational/56-lemonslice-transport.py similarity index 100% rename from examples/foundational/55-lemonslice-transport.py rename to examples/foundational/56-lemonslice-transport.py diff --git a/examples/foundational/README.md b/examples/foundational/README.md index 8fb60a0c2..04e88b7e7 100644 --- a/examples/foundational/README.md +++ b/examples/foundational/README.md @@ -121,7 +121,7 @@ uv run 07-interruptible.py -t twilio -x NGROK_HOST_NAME - **[19-openai-realtime-beta.py](./19-openai-realtime-beta.py)**: OpenAI Speech-to-Speech (Direct S2S, Function calls) - **[21-tavus-layer-tavus-transport.py](./21-tavus-layer-tavus-transport.py)**: Tavus digital twin (Avatar integration) - **[27-simli-layer.py](./27-simli-layer.py)**: Simli avatar integration (Video synchronization) -- **[55-lemonslice-transport.py](./55-lemonslice-transport.py)**: LemonSlice avatar integration (A/V Synced Avatar integration) +- **[56-lemonslice-transport.py](./56-lemonslice-transport.py)**: LemonSlice avatar integration (A/V Synced Avatar integration) ### Performance & Optimization diff --git a/uv.lock b/uv.lock index 49cfa089b..c8cba1b1b 100644 --- a/uv.lock +++ b/uv.lock @@ -2114,6 +2114,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/3f/9859f655d11901e7b2996c6e3d33e0caa9a1d4572c3bc61ed0faa64b2f4c/greenlet-3.3.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9bc885b89709d901859cf95179ec9f6bb67a3d2bb1f0e88456461bd4b7f8fd0d", size = 277747, upload-time = "2026-02-20T20:16:21.325Z" }, { url = "https://files.pythonhosted.org/packages/fb/07/cb284a8b5c6498dbd7cba35d31380bb123d7dceaa7907f606c8ff5993cbf/greenlet-3.3.2-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b568183cf65b94919be4438dc28416b234b678c608cafac8874dfeeb2a9bbe13", size = 579202, upload-time = "2026-02-20T20:47:28.955Z" }, { url = "https://files.pythonhosted.org/packages/ed/45/67922992b3a152f726163b19f890a85129a992f39607a2a53155de3448b8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:527fec58dc9f90efd594b9b700662ed3fb2493c2122067ac9c740d98080a620e", size = 590620, upload-time = "2026-02-20T20:55:55.581Z" }, + { url = "https://files.pythonhosted.org/packages/03/5f/6e2a7d80c353587751ef3d44bb947f0565ec008a2e0927821c007e96d3a7/greenlet-3.3.2-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:508c7f01f1791fbc8e011bd508f6794cb95397fdb198a46cb6635eb5b78d85a7", size = 602132, upload-time = "2026-02-20T21:02:43.261Z" }, { url = "https://files.pythonhosted.org/packages/ad/55/9f1ebb5a825215fadcc0f7d5073f6e79e3007e3282b14b22d6aba7ca6cb8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ad0c8917dd42a819fe77e6bdfcb84e3379c0de956469301d9fd36427a1ca501f", size = 591729, upload-time = "2026-02-20T20:20:58.395Z" }, { url = "https://files.pythonhosted.org/packages/24/b4/21f5455773d37f94b866eb3cf5caed88d6cea6dd2c6e1f9c34f463cba3ec/greenlet-3.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:97245cc10e5515dbc8c3104b2928f7f02b6813002770cfaffaf9a6e0fc2b94ef", size = 1551946, upload-time = "2026-02-20T20:49:31.102Z" }, { url = "https://files.pythonhosted.org/packages/00/68/91f061a926abead128fe1a87f0b453ccf07368666bd59ffa46016627a930/greenlet-3.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8c1fdd7d1b309ff0da81d60a9688a8bd044ac4e18b250320a96fc68d31c209ca", size = 1618494, upload-time = "2026-02-20T20:21:06.541Z" }, @@ -2121,6 +2122,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f3/47/16400cb42d18d7a6bb46f0626852c1718612e35dcb0dffa16bbaffdf5dd2/greenlet-3.3.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:c56692189a7d1c7606cb794be0a8381470d95c57ce5be03fb3d0ef57c7853b86", size = 278890, upload-time = "2026-02-20T20:19:39.263Z" }, { url = "https://files.pythonhosted.org/packages/a3/90/42762b77a5b6aa96cd8c0e80612663d39211e8ae8a6cd47c7f1249a66262/greenlet-3.3.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ebd458fa8285960f382841da585e02201b53a5ec2bac6b156fc623b5ce4499f", size = 581120, upload-time = "2026-02-20T20:47:30.161Z" }, { url = "https://files.pythonhosted.org/packages/bf/6f/f3d64f4fa0a9c7b5c5b3c810ff1df614540d5aa7d519261b53fba55d4df9/greenlet-3.3.2-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a443358b33c4ec7b05b79a7c8b466f5d275025e750298be7340f8fc63dff2a55", size = 594363, upload-time = "2026-02-20T20:55:56.965Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8b/1430a04657735a3f23116c2e0d5eb10220928846e4537a938a41b350bed6/greenlet-3.3.2-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4375a58e49522698d3e70cc0b801c19433021b5c37686f7ce9c65b0d5c8677d2", size = 605046, upload-time = "2026-02-20T21:02:45.234Z" }, { url = "https://files.pythonhosted.org/packages/72/83/3e06a52aca8128bdd4dcd67e932b809e76a96ab8c232a8b025b2850264c5/greenlet-3.3.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e2cd90d413acbf5e77ae41e5d3c9b3ac1d011a756d7284d7f3f2b806bbd6358", size = 594156, upload-time = "2026-02-20T20:20:59.955Z" }, { url = "https://files.pythonhosted.org/packages/70/79/0de5e62b873e08fe3cef7dbe84e5c4bc0e8ed0c7ff131bccb8405cd107c8/greenlet-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:442b6057453c8cb29b4fb36a2ac689382fc71112273726e2423f7f17dc73bf99", size = 1554649, upload-time = "2026-02-20T20:49:32.293Z" }, { url = "https://files.pythonhosted.org/packages/5a/00/32d30dee8389dc36d42170a9c66217757289e2afb0de59a3565260f38373/greenlet-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45abe8eb6339518180d5a7fa47fa01945414d7cca5ecb745346fc6a87d2750be", size = 1619472, upload-time = "2026-02-20T20:21:07.966Z" }, @@ -2129,6 +2131,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" }, { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" }, { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" }, { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" }, { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" }, { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" }, @@ -2137,6 +2140,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" }, { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" }, { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" }, + { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" }, { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" }, { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" }, { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" }, @@ -2145,6 +2149,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" }, { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, @@ -2153,6 +2158,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" }, { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, @@ -4595,6 +4601,9 @@ langchain = [ { name = "langchain-community" }, { name = "langchain-openai" }, ] +lemonslice = [ + { name = "daily-python" }, +] livekit = [ { name = "livekit" }, { name = "livekit-api" }, @@ -4802,6 +4811,7 @@ requires-dist = [ { name = "opentelemetry-sdk", marker = "extra == 'tracing'", specifier = ">=1.33.0" }, { name = "ormsgpack", marker = "extra == 'fish'", specifier = "~=1.7.0" }, { name = "pillow", specifier = ">=11.1.0,<13" }, + { name = "pipecat-ai", extras = ["daily"], marker = "extra == 'lemonslice'" }, { name = "pipecat-ai", extras = ["nvidia"], marker = "extra == 'riva'" }, { name = "pipecat-ai", extras = ["websockets-base"], marker = "extra == 'assemblyai'" }, { name = "pipecat-ai", extras = ["websockets-base"], marker = "extra == 'asyncai'" }, @@ -4857,7 +4867,7 @@ requires-dist = [ { name = "wait-for2", marker = "python_full_version < '3.12'", specifier = ">=0.4.1" }, { name = "websockets", marker = "extra == 'websockets-base'", specifier = ">=13.1,<16.0" }, ] -provides-extras = ["aic", "anthropic", "assemblyai", "asyncai", "aws", "aws-nova-sonic", "azure", "cartesia", "camb", "cerebras", "daily", "deepgram", "deepseek", "elevenlabs", "fal", "fireworks", "fish", "gladia", "google", "gradium", "grok", "groq", "gstreamer", "heygen", "hume", "inworld", "koala", "kokoro", "krisp", "langchain", "livekit", "lmnt", "local", "local-smart-turn", "mcp", "mem0", "mistral", "mlx-whisper", "moondream", "neuphonic", "noisereduce", "nvidia", "openai", "rnnoise", "openpipe", "openrouter", "perplexity", "piper", "qwen", "remote-smart-turn", "resembleai", "rime", "riva", "runner", "sagemaker", "sambanova", "sarvam", "sentry", "silero", "simli", "soniox", "soundfile", "speechmatics", "strands", "tavus", "together", "tracing", "ultravox", "webrtc", "websocket", "websockets-base", "whisper"] +provides-extras = ["aic", "anthropic", "assemblyai", "asyncai", "aws", "aws-nova-sonic", "azure", "cartesia", "camb", "cerebras", "daily", "deepgram", "deepseek", "elevenlabs", "fal", "fireworks", "fish", "gladia", "google", "gradium", "grok", "groq", "gstreamer", "heygen", "hume", "inworld", "koala", "kokoro", "krisp", "langchain", "lemonslice", "livekit", "lmnt", "local", "local-smart-turn", "mcp", "mem0", "mistral", "mlx-whisper", "moondream", "neuphonic", "noisereduce", "nvidia", "openai", "rnnoise", "openpipe", "openrouter", "perplexity", "piper", "qwen", "remote-smart-turn", "resembleai", "rime", "riva", "runner", "sagemaker", "sambanova", "sarvam", "sentry", "silero", "simli", "soniox", "soundfile", "speechmatics", "strands", "tavus", "together", "tracing", "ultravox", "webrtc", "websocket", "websockets-base", "whisper"] [package.metadata.requires-dev] dev = [ From b449515410eac0b577af86d82abf71225bb58460 Mon Sep 17 00:00:00 2001 From: zack Date: Mon, 2 Mar 2026 17:54:31 -0500 Subject: [PATCH 59/68] Address PR review feedback: remove debug logs, fix hasattr logic, add VADAnalyzer --- .../07o-interruptible-assemblyai-stt.py | 6 +- src/pipecat/services/assemblyai/stt.py | 86 ++++++++----------- 2 files changed, 40 insertions(+), 52 deletions(-) diff --git a/examples/foundational/07o-interruptible-assemblyai-stt.py b/examples/foundational/07o-interruptible-assemblyai-stt.py index 2765f8590..01d53914d 100644 --- a/examples/foundational/07o-interruptible-assemblyai-stt.py +++ b/examples/foundational/07o-interruptible-assemblyai-stt.py @@ -10,6 +10,7 @@ import os from dotenv import load_dotenv from loguru import logger +from pipecat.audio.vad.silero import SileroVADAnalyzer from pipecat.frames.frames import LLMRunFrame from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner @@ -122,7 +123,10 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): context = LLMContext(messages) user_aggregator, assistant_aggregator = LLMContextAggregatorPair( context, - user_params=LLMUserAggregatorParams(user_turn_strategies=ExternalUserTurnStrategies()), + user_params=LLMUserAggregatorParams( + user_turn_strategies=ExternalUserTurnStrategies(), + vad_analyzer=SileroVADAnalyzer(), + ), ) pipeline = Pipeline( diff --git a/src/pipecat/services/assemblyai/stt.py b/src/pipecat/services/assemblyai/stt.py index bbbdad3f8..2e14b1a66 100644 --- a/src/pipecat/services/assemblyai/stt.py +++ b/src/pipecat/services/assemblyai/stt.py @@ -212,7 +212,6 @@ class AssemblyAISTTService(WebsocketSTTService): self._chunk_size_bytes = 0 self._user_speaking = False - self._vad_speaking = False def _configure_pipecat_turn_mode( self, connection_params: AssemblyAIConnectionParams, is_u3_pro: bool @@ -320,48 +319,44 @@ class AssemblyAISTTService(WebsocketSTTService): old_conn_params = changed.get("connection_params") # Check each potentially changed parameter - if hasattr(conn_params, "keyterms_prompt"): - if ( - old_conn_params is None - or conn_params.keyterms_prompt != old_conn_params.keyterms_prompt - ): - if conn_params.keyterms_prompt is not None: - update_config["keyterms_prompt"] = conn_params.keyterms_prompt - logger.info(f"Updating keyterms_prompt to: {conn_params.keyterms_prompt}") + if ( + old_conn_params is None + or conn_params.keyterms_prompt != old_conn_params.keyterms_prompt + ): + if conn_params.keyterms_prompt is not None: + update_config["keyterms_prompt"] = conn_params.keyterms_prompt + logger.info(f"Updating keyterms_prompt to: {conn_params.keyterms_prompt}") - if hasattr(conn_params, "prompt"): - if old_conn_params is None or conn_params.prompt != old_conn_params.prompt: - if conn_params.prompt is not None: - if conn_params.speech_model != "u3-rt-pro": - logger.warning( - f"prompt parameter is only supported with u3-rt-pro model, " - f"current model is {conn_params.speech_model}" - ) - else: - update_config["prompt"] = conn_params.prompt - logger.info(f"Updating prompt") - - if hasattr(conn_params, "max_turn_silence"): - if ( - old_conn_params is None - or conn_params.max_turn_silence != old_conn_params.max_turn_silence - ): - if conn_params.max_turn_silence is not None: - update_config["max_turn_silence"] = conn_params.max_turn_silence - logger.info( - f"Updating max_turn_silence to: {conn_params.max_turn_silence}ms" + if old_conn_params is None or conn_params.prompt != old_conn_params.prompt: + if conn_params.prompt is not None: + if conn_params.speech_model != "u3-rt-pro": + logger.warning( + f"prompt parameter is only supported with u3-rt-pro model, " + f"current model is {conn_params.speech_model}" ) + else: + update_config["prompt"] = conn_params.prompt + logger.info(f"Updating prompt") - if hasattr(conn_params, "min_turn_silence"): - if ( - old_conn_params is None - or conn_params.min_turn_silence != old_conn_params.min_turn_silence - ): - if conn_params.min_turn_silence is not None: - update_config["min_turn_silence"] = conn_params.min_turn_silence - logger.info( - f"Updating min_turn_silence to: {conn_params.min_turn_silence}ms" - ) + if ( + old_conn_params is None + or conn_params.max_turn_silence != old_conn_params.max_turn_silence + ): + if conn_params.max_turn_silence is not None: + update_config["max_turn_silence"] = conn_params.max_turn_silence + logger.info( + f"Updating max_turn_silence to: {conn_params.max_turn_silence}ms" + ) + + if ( + old_conn_params is None + or conn_params.min_turn_silence != old_conn_params.min_turn_silence + ): + if conn_params.min_turn_silence is not None: + update_config["min_turn_silence"] = conn_params.min_turn_silence + logger.info( + f"Updating min_turn_silence to: {conn_params.min_turn_silence}ms" + ) # Send update if we have parameters to update if len(update_config) > 1: # More than just "type" @@ -639,20 +634,14 @@ class AssemblyAISTTService(WebsocketSTTService): Only applies to Mode 2 (STT turn detection). In Mode 1, VAD + smart turn analyzer handle interruptions via the aggregator. """ - logger.debug( - f"{self} SpeechStarted received (vad_force_turn_endpoint={self._vad_force_turn_endpoint})" - ) if self._vad_force_turn_endpoint: - logger.debug(f"{self} SpeechStarted ignored in Pipecat mode") return # Mode 1: handled by aggregator - logger.debug(f"{self} Processing SpeechStarted in STT mode") await self.start_processing_metrics() await self.broadcast_frame(UserStartedSpeakingFrame) if self._should_interrupt: await self.push_interruption_task_frame_and_wait() self._user_speaking = True - logger.debug(f"{self} _user_speaking set to True") async def _handle_termination(self, message: TerminationMessage): """Handle termination message.""" @@ -730,7 +719,6 @@ class AssemblyAISTTService(WebsocketSTTService): await self._trace_transcription(transcript_text, True, language) await self.stop_processing_metrics() else: - logger.debug(f'{self} Interim transcript: "{transcript_text}"') await self.push_frame( InterimTranscriptionFrame( transcript_text, @@ -744,10 +732,6 @@ class AssemblyAISTTService(WebsocketSTTService): # --- Mode 2: STT turn detection --- # SpeechStarted always arrives before transcripts with u3-rt-pro, # so UserStartedSpeakingFrame is guaranteed to be broadcast first. - logger.debug( - f"{self} Transcript received in STT mode (_user_speaking={self._user_speaking})" - ) - if is_final_turn: # STT mode: AssemblyAI controls finalization, just mark as finalized await self.push_frame( From 32773b42d694cddfd7cbdc1d86743781b47d8d4c Mon Sep 17 00:00:00 2001 From: zack Date: Mon, 2 Mar 2026 18:08:46 -0500 Subject: [PATCH 60/68] Improve terminology: rename file and replace 'STT mode' with 'AssemblyAI turn detection' - Rename 07o-interruptible-assemblyai-stt.py -> 07o-interruptible-assemblyai-turn-detection.py - Replace 'STT mode' with 'AssemblyAI turn detection mode' throughout codebase - Replace 'Mode 1'/'Mode 2' with descriptive 'Pipecat turn detection'/'AssemblyAI turn detection' - Update changelog to use 'built-in turn detection' terminology - Addresses PR feedback about confusing terminology --- changelog/3856.added.md | 2 +- ...nterruptible-assemblyai-turn-detection.py} | 14 ++++---- src/pipecat/services/assemblyai/stt.py | 33 ++++++++++--------- 3 files changed, 25 insertions(+), 24 deletions(-) rename examples/foundational/{07o-interruptible-assemblyai-stt.py => 07o-interruptible-assemblyai-turn-detection.py} (93%) diff --git a/changelog/3856.added.md b/changelog/3856.added.md index 95b656058..8074a5281 100644 --- a/changelog/3856.added.md +++ b/changelog/3856.added.md @@ -1 +1 @@ -- Add AssemblyAI u3-rt-pro model support with STT-controlled turn detection mode +- Add AssemblyAI u3-rt-pro model support with built-in turn detection mode diff --git a/examples/foundational/07o-interruptible-assemblyai-stt.py b/examples/foundational/07o-interruptible-assemblyai-turn-detection.py similarity index 93% rename from examples/foundational/07o-interruptible-assemblyai-stt.py rename to examples/foundational/07o-interruptible-assemblyai-turn-detection.py index 01d53914d..cf052ada4 100644 --- a/examples/foundational/07o-interruptible-assemblyai-stt.py +++ b/examples/foundational/07o-interruptible-assemblyai-turn-detection.py @@ -53,20 +53,20 @@ transport_params = { async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): - """AssemblyAI u3-rt-pro STT Example with STT-Controlled Turn Detection + """AssemblyAI u3-rt-pro with Built-in Turn Detection This example demonstrates using AssemblyAI's u3-rt-pro Speech-to-Text model - with STT-controlled turn detection for more natural conversation flow. + with AssemblyAI's built-in turn detection for more natural conversation flow. Key features: - 1. STT-Controlled Turn Detection - - Set `vad_force_turn_endpoint=False` to enable STT mode + 1. AssemblyAI Turn Detection + - Set `vad_force_turn_endpoint=False` to use AssemblyAI's built-in turn detection - AssemblyAI's model determines when user starts/stops speaking - - Uses `ExternalUserTurnStrategies` instead of Pipecat's VAD + - Uses `ExternalUserTurnStrategies` to delegate turn control to AssemblyAI - More natural turn detection based on speech patterns and pauses - 2. Advanced Turn Detection Tuning (STT Mode) + 2. Advanced Turn Detection Tuning - `min_turn_silence`: Minimum silence (ms) when confident about end-of-turn. Lower values = faster responses. Default: 100ms - `max_turn_silence`: Maximum silence (ms) before forcing end-of-turn. @@ -93,7 +93,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): stt = AssemblyAISTTService( api_key=os.getenv("ASSEMBLYAI_API_KEY"), - vad_force_turn_endpoint=False, # Enable STT-controlled turn detection + vad_force_turn_endpoint=False, # Use AssemblyAI's built-in turn detection connection_params=AssemblyAIConnectionParams( speech_model="u3-rt-pro", # Optional: Tune turn detection timing (defaults shown below) diff --git a/src/pipecat/services/assemblyai/stt.py b/src/pipecat/services/assemblyai/stt.py index 2e14b1a66..cccd3d8b0 100644 --- a/src/pipecat/services/assemblyai/stt.py +++ b/src/pipecat/services/assemblyai/stt.py @@ -131,14 +131,15 @@ class AssemblyAISTTService(WebsocketSTTService): - max_turn_silence is ALWAYS set equal to min_turn_silence - VAD stop sends ForceEndpoint as ceiling - No UserStarted/StoppedSpeakingFrame emitted from STT - When False (STT mode, u3-rt-pro only): AssemblyAI's model controls turn endings. + When False (AssemblyAI turn detection mode, u3-rt-pro only): AssemblyAI's model + controls turn endings using built-in turn detection. - Uses AssemblyAI API defaults for all parameters (unless user explicitly sets them) - Respects all user-provided connection_params as-is - Emits UserStarted/StoppedSpeakingFrame from STT - No ForceEndpoint on VAD stop should_interrupt: Whether to interrupt the bot when the user starts speaking - in STT mode (vad_force_turn_endpoint=False). Only applies to STT mode. - Defaults to True. + in AssemblyAI turn detection mode (vad_force_turn_endpoint=False). Only applies + when using AssemblyAI's built-in turn detection. Defaults to True. speaker_format: Optional format string for speaker labels when diarization is enabled. Use {speaker} for speaker label and {text} for transcript text. Example: "<{speaker}>{text}" or "{speaker}: {text}" @@ -147,13 +148,13 @@ class AssemblyAISTTService(WebsocketSTTService): Override for your deployment. See https://github.com/pipecat-ai/stt-benchmark **kwargs: Additional arguments passed to parent STTService class. """ - # STT turn detection (vad_force_turn_endpoint=False) requires the + # AssemblyAI turn detection mode (vad_force_turn_endpoint=False) requires the # SpeechStarted event for reliable barge-in. Only u3-rt-pro supports # this. Other models must use Pipecat turn detection. is_u3_pro = connection_params.speech_model == "u3-rt-pro" if not vad_force_turn_endpoint and not is_u3_pro: raise ValueError( - f"STT turn detection (vad_force_turn_endpoint=False) requires " + f"AssemblyAI turn detection mode (vad_force_turn_endpoint=False) requires " f"u3-rt-pro for SpeechStarted support. Either set " f"vad_force_turn_endpoint=True for {connection_params.speech_model}, " f"or use speech_model='u3-rt-pro'." @@ -256,7 +257,7 @@ class AssemblyAISTTService(WebsocketSTTService): f"OVERRIDDEN in Pipecat mode (vad_force_turn_endpoint=True). It will be set to " f"{min_silence}ms (matching min_turn_silence) and SENT to " f"AssemblyAI to avoid double turn detection. To use your max_turn_silence as-is, " - f"switch to STT mode (vad_force_turn_endpoint=False)." + f"switch to AssemblyAI turn detection mode (vad_force_turn_endpoint=False)." ) updates = { @@ -624,18 +625,18 @@ class AssemblyAISTTService(WebsocketSTTService): await self.push_error(error_msg=f"Unknown error occurred: {e}", exception=e) async def _handle_speech_started(self, message: SpeechStartedMessage): - """Handle SpeechStarted event — fast barge-in for Mode 2. + """Handle SpeechStarted event — fast barge-in for AssemblyAI turn detection. Broadcasts UserStartedSpeakingFrame to signal the start of user speech, then pushes an interruption to cancel any bot audio. SpeechStarted fires before any transcript arrives, so the turn is cleanly started before any transcription frames are pushed. - Only applies to Mode 2 (STT turn detection). In Mode 1, VAD + - smart turn analyzer handle interruptions via the aggregator. + Only applies when using AssemblyAI's built-in turn detection. When using + Pipecat turn detection, VAD + smart turn analyzer handle interruptions. """ if self._vad_force_turn_endpoint: - return # Mode 1: handled by aggregator + return # Pipecat mode: handled by aggregator await self.start_processing_metrics() await self.broadcast_frame(UserStartedSpeakingFrame) @@ -655,15 +656,15 @@ class AssemblyAISTTService(WebsocketSTTService): await self.push_frame(EndFrame()) async def _handle_transcription(self, message: TurnMessage): - """Handle transcription results with two-mode turn detection. + """Handle transcription results with two turn detection modes. - Mode 1 (vad_force_turn_endpoint=True, Pipecat turn detection): + Pipecat turn detection (vad_force_turn_endpoint=True): - No UserStarted/StoppedSpeakingFrame from STT - end_of_turn → TranscriptionFrame (finalized set by base class if this is a ForceEndpoint response) - else → InterimTranscriptionFrame - Mode 2 (vad_force_turn_endpoint=False, STT turn detection): + AssemblyAI turn detection (vad_force_turn_endpoint=False): - UserStartedSpeakingFrame on first transcript - end_of_turn → TranscriptionFrame + UserStoppedSpeakingFrame - else → InterimTranscriptionFrame @@ -700,7 +701,7 @@ class AssemblyAISTTService(WebsocketSTTService): ) if self._vad_force_turn_endpoint: - # --- Mode 1: Pipecat turn detection --- + # --- Pipecat turn detection mode --- # No UserStarted/StoppedSpeakingFrame — VAD + smart turn analyzer handle this if is_final_turn: finalize_confirmed = bool(message.turn_is_formatted) @@ -729,11 +730,11 @@ class AssemblyAISTTService(WebsocketSTTService): ) ) else: - # --- Mode 2: STT turn detection --- + # --- AssemblyAI turn detection mode --- # SpeechStarted always arrives before transcripts with u3-rt-pro, # so UserStartedSpeakingFrame is guaranteed to be broadcast first. if is_final_turn: - # STT mode: AssemblyAI controls finalization, just mark as finalized + # AssemblyAI controls finalization, just mark as finalized await self.push_frame( TranscriptionFrame( transcript_text, From 088eb9b01c0706dcc49769ce9c08a0cbbf49b520 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Mon, 2 Mar 2026 15:20:22 -0800 Subject: [PATCH 61/68] examples: update to new sagemaker packages --- examples/foundational/07c-interruptible-deepgram-sagemaker.py | 4 ++-- .../55a-update-settings-deepgram-sagemaker-stt.py | 2 +- .../55q-update-settings-deepgram-sagemaker-tts.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/foundational/07c-interruptible-deepgram-sagemaker.py b/examples/foundational/07c-interruptible-deepgram-sagemaker.py index aced7666f..7a4cd4297 100644 --- a/examples/foundational/07c-interruptible-deepgram-sagemaker.py +++ b/examples/foundational/07c-interruptible-deepgram-sagemaker.py @@ -23,8 +23,8 @@ from pipecat.processors.aggregators.llm_response_universal import ( from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport from pipecat.services.aws.llm import AWSBedrockLLMService -from pipecat.services.deepgram.stt_sagemaker import DeepgramSageMakerSTTService -from pipecat.services.deepgram.tts_sagemaker import DeepgramSageMakerTTSService +from pipecat.services.deepgram.sagemaker.stt import DeepgramSageMakerSTTService +from pipecat.services.deepgram.sagemaker.tts import DeepgramSageMakerTTSService from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.daily.transport import DailyParams from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams diff --git a/examples/foundational/55a-update-settings-deepgram-sagemaker-stt.py b/examples/foundational/55a-update-settings-deepgram-sagemaker-stt.py index e8094183a..04451e85c 100644 --- a/examples/foundational/55a-update-settings-deepgram-sagemaker-stt.py +++ b/examples/foundational/55a-update-settings-deepgram-sagemaker-stt.py @@ -24,7 +24,7 @@ from pipecat.processors.aggregators.llm_response_universal import ( from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport from pipecat.services.cartesia.tts import CartesiaTTSService -from pipecat.services.deepgram.stt_sagemaker import ( +from pipecat.services.deepgram.sagemaker.stt import ( DeepgramSageMakerSTTService, DeepgramSageMakerSTTSettings, ) diff --git a/examples/foundational/55q-update-settings-deepgram-sagemaker-tts.py b/examples/foundational/55q-update-settings-deepgram-sagemaker-tts.py index 85087d0d2..14958b9d2 100644 --- a/examples/foundational/55q-update-settings-deepgram-sagemaker-tts.py +++ b/examples/foundational/55q-update-settings-deepgram-sagemaker-tts.py @@ -22,11 +22,11 @@ from pipecat.processors.aggregators.llm_response_universal import ( ) from pipecat.runner.types import RunnerArguments from pipecat.runner.utils import create_transport -from pipecat.services.deepgram.stt import DeepgramSTTService -from pipecat.services.deepgram.tts_sagemaker import ( +from pipecat.services.deepgram.sagemaker.tts import ( DeepgramSageMakerTTSService, DeepgramSageMakerTTSSettings, ) +from pipecat.services.deepgram.stt import DeepgramSTTService from pipecat.services.openai.llm import OpenAILLMService from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.daily.transport import DailyParams From 303616599fffffcab61ac0738f38999e3b0012ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Mon, 2 Mar 2026 14:48:20 -0800 Subject: [PATCH 62/68] Add changelog for #3902 --- changelog/3902.changed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/3902.changed.md diff --git a/changelog/3902.changed.md b/changelog/3902.changed.md new file mode 100644 index 000000000..95d3d592c --- /dev/null +++ b/changelog/3902.changed.md @@ -0,0 +1 @@ +- Moved `pipecat.services.deepgram.stt_sagemaker` and `pipecat.services.deepgram.tts_sagemaker` to `pipecat.services.deepgram.sagemaker.stt` and `pipecat.services.deepgram.sagemaker.tts`. The old import paths still work but emit a `DeprecationWarning`. From c6c2c5ba05f66d04c0999037603dc75d07fed476 Mon Sep 17 00:00:00 2001 From: zack Date: Mon, 2 Mar 2026 18:25:25 -0500 Subject: [PATCH 63/68] Fix end_of_turn_confidence_threshold: set to 1.0 (not 0.0) for universal-streaming - u3-rt-pro: Does not set parameter (not used) - universal-streaming models: Set to 1.0 to maintain fast response - This ensures fast response time matches previous implementation --- src/pipecat/services/assemblyai/stt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pipecat/services/assemblyai/stt.py b/src/pipecat/services/assemblyai/stt.py index cccd3d8b0..659fa3b4d 100644 --- a/src/pipecat/services/assemblyai/stt.py +++ b/src/pipecat/services/assemblyai/stt.py @@ -267,7 +267,7 @@ class AssemblyAISTTService(WebsocketSTTService): else: # universal-streaming: Different configuration (works differently) updates = { - "end_of_turn_confidence_threshold": 0.0, + "end_of_turn_confidence_threshold": 1.0, "min_turn_silence": 160, } From 7dbb130666acd5ca9177f4a65b82194d60077b52 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Mon, 2 Mar 2026 19:23:42 -0500 Subject: [PATCH 64/68] Add chronological_events utility function to display UserBotLatencyObserver report --- .../foundational/29-turn-tracking-observer.py | 26 +----- .../observers/user_bot_latency_observer.py | 29 +++++++ tests/test_user_bot_latency_observer.py | 84 ++++++++++++++++++- 3 files changed, 114 insertions(+), 25 deletions(-) diff --git a/examples/foundational/29-turn-tracking-observer.py b/examples/foundational/29-turn-tracking-observer.py index 476dc4612..cf85972e1 100644 --- a/examples/foundational/29-turn-tracking-observer.py +++ b/examples/foundational/29-turn-tracking-observer.py @@ -184,30 +184,8 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): @latency_observer.event_handler("on_latency_breakdown") async def on_latency_breakdown(observer, breakdown): - # Display a sequential waterfall that roughly adds up to the total. - # User turn is the first stage: user silence → turn release. - # The STT TTFB is shown as context within the user turn since - # it's a component of that time (along with VAD silence and any - # turn analyzer delay). - stt_ttfb = next((t for t in breakdown.ttfb if "STT" in t.processor), None) - if breakdown.user_turn_secs is not None: - stt_note = f" (STT: {stt_ttfb.duration_secs:.3f}s)" if stt_ttfb else "" - logger.info(f" User turn: {breakdown.user_turn_secs:.3f}s{stt_note}") - - # Show non-STT TTFBs, inserting function calls after the first - # LLM TTFB (which triggered the calls) for a chronological waterfall. - non_stt = [t for t in breakdown.ttfb if t is not stt_ttfb] - fc_shown = False - for ttfb in non_stt: - logger.info(f" {ttfb.processor}: TTFB {ttfb.duration_secs:.3f}s") - if not fc_shown and breakdown.function_calls: - for fc in breakdown.function_calls: - logger.info(f" {fc.function_name}: {fc.duration_secs:.3f}s") - fc_shown = True - - if breakdown.text_aggregation: - ta = breakdown.text_aggregation - logger.info(f" {ta.processor}: text aggregation {ta.duration_secs:.3f}s") + for event in breakdown.chronological_events(): + logger.info(f" {event}") @transport.event_handler("on_client_connected") async def on_client_connected(transport, client): diff --git a/src/pipecat/observers/user_bot_latency_observer.py b/src/pipecat/observers/user_bot_latency_observer.py index aa0887e30..0672b689c 100644 --- a/src/pipecat/observers/user_bot_latency_observer.py +++ b/src/pipecat/observers/user_bot_latency_observer.py @@ -111,6 +111,35 @@ class LatencyBreakdown(BaseModel): user_turn_secs: Optional[float] = None function_calls: List[FunctionCallMetrics] = Field(default_factory=list) + def chronological_events(self) -> List[str]: + """Return human-readable event labels sorted by start time. + + Collects all sub-metrics into a flat list, sorts by ``start_time``, + and returns formatted strings suitable for logging. + + Returns: + List of formatted strings, one per event, in chronological order. + """ + events: List[tuple] = [] + + if self.user_turn_start_time is not None and self.user_turn_secs is not None: + events.append((self.user_turn_start_time, f"User turn: {self.user_turn_secs:.3f}s")) + + for t in self.ttfb: + events.append((t.start_time, f"{t.processor}: TTFB {t.duration_secs:.3f}s")) + + for fc in self.function_calls: + events.append((fc.start_time, f"{fc.function_name}: {fc.duration_secs:.3f}s")) + + if self.text_aggregation: + ta = self.text_aggregation + events.append( + (ta.start_time, f"{ta.processor}: text aggregation {ta.duration_secs:.3f}s") + ) + + events.sort(key=lambda e: e[0]) + return [label for _, label in events] + class UserBotLatencyObserver(BaseObserver): """Observer that tracks user-to-bot response latency. diff --git a/tests/test_user_bot_latency_observer.py b/tests/test_user_bot_latency_observer.py index 42d5d3367..96c24724b 100644 --- a/tests/test_user_bot_latency_observer.py +++ b/tests/test_user_bot_latency_observer.py @@ -15,7 +15,13 @@ from pipecat.metrics.metrics import ( TextAggregationMetricsData, TTFBMetricsData, ) -from pipecat.observers.user_bot_latency_observer import UserBotLatencyObserver +from pipecat.observers.user_bot_latency_observer import ( + FunctionCallMetrics, + LatencyBreakdown, + TextAggregationBreakdownMetrics, + TTFBBreakdownMetrics, + UserBotLatencyObserver, +) from pipecat.processors.filters.identity_filter import IdentityFilter from pipecat.tests.utils import SleepFrame, run_test @@ -545,5 +551,81 @@ class TestUserBotLatencyObserver(unittest.IsolatedAsyncioTestCase): self.assertEqual(len(breakdowns[0].function_calls), 0) +class TestLatencyBreakdownChronologicalEvents(unittest.TestCase): + """Tests for LatencyBreakdown.chronological_events().""" + + def test_events_sorted_by_start_time(self): + """Test that events are returned in chronological order.""" + breakdown = LatencyBreakdown( + user_turn_start_time=100.0, + user_turn_secs=0.150, + ttfb=[ + TTFBBreakdownMetrics( + processor="OpenAILLMService#0", + model="gpt-4o", + start_time=100.200, + duration_secs=0.250, + ), + TTFBBreakdownMetrics( + processor="DeepgramSTTService#0", + start_time=100.050, + duration_secs=0.080, + ), + TTFBBreakdownMetrics( + processor="CartesiaTTSService#0", + start_time=100.500, + duration_secs=0.070, + ), + ], + function_calls=[ + FunctionCallMetrics( + function_name="get_weather", + start_time=100.450, + duration_secs=0.120, + ), + ], + text_aggregation=TextAggregationBreakdownMetrics( + processor="CartesiaTTSService#0", + start_time=100.480, + duration_secs=0.030, + ), + ) + + events = breakdown.chronological_events() + + self.assertEqual(len(events), 6) + self.assertEqual(events[0], "User turn: 0.150s") + self.assertEqual(events[1], "DeepgramSTTService#0: TTFB 0.080s") + self.assertEqual(events[2], "OpenAILLMService#0: TTFB 0.250s") + self.assertEqual(events[3], "get_weather: 0.120s") + self.assertEqual(events[4], "CartesiaTTSService#0: text aggregation 0.030s") + self.assertEqual(events[5], "CartesiaTTSService#0: TTFB 0.070s") + + def test_empty_breakdown(self): + """Test that an empty breakdown returns no events.""" + breakdown = LatencyBreakdown() + self.assertEqual(breakdown.chronological_events(), []) + + def test_user_turn_requires_both_fields(self): + """Test that user turn is only included when both start_time and secs are set.""" + # Only start_time, no duration + breakdown = LatencyBreakdown(user_turn_start_time=100.0) + self.assertEqual(breakdown.chronological_events(), []) + + # Only duration, no start_time + breakdown = LatencyBreakdown(user_turn_secs=0.150) + self.assertEqual(breakdown.chronological_events(), []) + + def test_ttfb_only(self): + """Test breakdown with only TTFB metrics.""" + breakdown = LatencyBreakdown( + ttfb=[ + TTFBBreakdownMetrics(processor="LLM#0", start_time=100.0, duration_secs=0.200), + ], + ) + events = breakdown.chronological_events() + self.assertEqual(events, ["LLM#0: TTFB 0.200s"]) + + if __name__ == "__main__": unittest.main() From 5952ea711c33750b1fd71985f21ac8f30323f50d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Mon, 2 Mar 2026 16:42:58 -0800 Subject: [PATCH 65/68] update uv.lock --- uv.lock | 388 +++++++++++++++++++++++++++++--------------------------- 1 file changed, 198 insertions(+), 190 deletions(-) diff --git a/uv.lock b/uv.lock index c8cba1b1b..70bab79c7 100644 --- a/uv.lock +++ b/uv.lock @@ -1755,11 +1755,11 @@ wheels = [ [[package]] name = "filelock" -version = "3.24.3" +version = "3.25.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/73/92/a8e2479937ff39185d20dd6a851c1a63e55849e447a55e798cc2e1f49c65/filelock-3.24.3.tar.gz", hash = "sha256:011a5644dc937c22699943ebbfc46e969cdde3e171470a6e40b9533e5a72affa", size = 37935, upload-time = "2026-02-19T00:48:20.543Z" } +sdist = { url = "https://files.pythonhosted.org/packages/77/18/a1fd2231c679dcb9726204645721b12498aeac28e1ad0601038f94b42556/filelock-3.25.0.tar.gz", hash = "sha256:8f00faf3abf9dc730a1ffe9c354ae5c04e079ab7d3a683b7c32da5dd05f26af3", size = 40158, upload-time = "2026-03-01T15:08:45.916Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/0f/5d0c71a1aefeb08efff26272149e07ab922b64f46c63363756224bd6872e/filelock-3.24.3-py3-none-any.whl", hash = "sha256:426e9a4660391f7f8a810d71b0555bce9008b0a1cc342ab1f6947d37639e002d", size = 24331, upload-time = "2026-02-19T00:48:18.465Z" }, + { url = "https://files.pythonhosted.org/packages/f9/0b/de6f54d4a8bedfe8645c41497f3c18d749f0bd3218170c667bf4b81d0cdd/filelock-3.25.0-py3-none-any.whl", hash = "sha256:5ccf8069f7948f494968fc0713c10e5c182a9c9d9eef3a636307a20c2490f047", size = 26427, upload-time = "2026-03-01T15:08:44.593Z" }, ] [[package]] @@ -2114,7 +2114,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/3f/9859f655d11901e7b2996c6e3d33e0caa9a1d4572c3bc61ed0faa64b2f4c/greenlet-3.3.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9bc885b89709d901859cf95179ec9f6bb67a3d2bb1f0e88456461bd4b7f8fd0d", size = 277747, upload-time = "2026-02-20T20:16:21.325Z" }, { url = "https://files.pythonhosted.org/packages/fb/07/cb284a8b5c6498dbd7cba35d31380bb123d7dceaa7907f606c8ff5993cbf/greenlet-3.3.2-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b568183cf65b94919be4438dc28416b234b678c608cafac8874dfeeb2a9bbe13", size = 579202, upload-time = "2026-02-20T20:47:28.955Z" }, { url = "https://files.pythonhosted.org/packages/ed/45/67922992b3a152f726163b19f890a85129a992f39607a2a53155de3448b8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:527fec58dc9f90efd594b9b700662ed3fb2493c2122067ac9c740d98080a620e", size = 590620, upload-time = "2026-02-20T20:55:55.581Z" }, - { url = "https://files.pythonhosted.org/packages/03/5f/6e2a7d80c353587751ef3d44bb947f0565ec008a2e0927821c007e96d3a7/greenlet-3.3.2-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:508c7f01f1791fbc8e011bd508f6794cb95397fdb198a46cb6635eb5b78d85a7", size = 602132, upload-time = "2026-02-20T21:02:43.261Z" }, { url = "https://files.pythonhosted.org/packages/ad/55/9f1ebb5a825215fadcc0f7d5073f6e79e3007e3282b14b22d6aba7ca6cb8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ad0c8917dd42a819fe77e6bdfcb84e3379c0de956469301d9fd36427a1ca501f", size = 591729, upload-time = "2026-02-20T20:20:58.395Z" }, { url = "https://files.pythonhosted.org/packages/24/b4/21f5455773d37f94b866eb3cf5caed88d6cea6dd2c6e1f9c34f463cba3ec/greenlet-3.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:97245cc10e5515dbc8c3104b2928f7f02b6813002770cfaffaf9a6e0fc2b94ef", size = 1551946, upload-time = "2026-02-20T20:49:31.102Z" }, { url = "https://files.pythonhosted.org/packages/00/68/91f061a926abead128fe1a87f0b453ccf07368666bd59ffa46016627a930/greenlet-3.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8c1fdd7d1b309ff0da81d60a9688a8bd044ac4e18b250320a96fc68d31c209ca", size = 1618494, upload-time = "2026-02-20T20:21:06.541Z" }, @@ -2122,7 +2121,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f3/47/16400cb42d18d7a6bb46f0626852c1718612e35dcb0dffa16bbaffdf5dd2/greenlet-3.3.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:c56692189a7d1c7606cb794be0a8381470d95c57ce5be03fb3d0ef57c7853b86", size = 278890, upload-time = "2026-02-20T20:19:39.263Z" }, { url = "https://files.pythonhosted.org/packages/a3/90/42762b77a5b6aa96cd8c0e80612663d39211e8ae8a6cd47c7f1249a66262/greenlet-3.3.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ebd458fa8285960f382841da585e02201b53a5ec2bac6b156fc623b5ce4499f", size = 581120, upload-time = "2026-02-20T20:47:30.161Z" }, { url = "https://files.pythonhosted.org/packages/bf/6f/f3d64f4fa0a9c7b5c5b3c810ff1df614540d5aa7d519261b53fba55d4df9/greenlet-3.3.2-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a443358b33c4ec7b05b79a7c8b466f5d275025e750298be7340f8fc63dff2a55", size = 594363, upload-time = "2026-02-20T20:55:56.965Z" }, - { url = "https://files.pythonhosted.org/packages/9c/8b/1430a04657735a3f23116c2e0d5eb10220928846e4537a938a41b350bed6/greenlet-3.3.2-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4375a58e49522698d3e70cc0b801c19433021b5c37686f7ce9c65b0d5c8677d2", size = 605046, upload-time = "2026-02-20T21:02:45.234Z" }, { url = "https://files.pythonhosted.org/packages/72/83/3e06a52aca8128bdd4dcd67e932b809e76a96ab8c232a8b025b2850264c5/greenlet-3.3.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e2cd90d413acbf5e77ae41e5d3c9b3ac1d011a756d7284d7f3f2b806bbd6358", size = 594156, upload-time = "2026-02-20T20:20:59.955Z" }, { url = "https://files.pythonhosted.org/packages/70/79/0de5e62b873e08fe3cef7dbe84e5c4bc0e8ed0c7ff131bccb8405cd107c8/greenlet-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:442b6057453c8cb29b4fb36a2ac689382fc71112273726e2423f7f17dc73bf99", size = 1554649, upload-time = "2026-02-20T20:49:32.293Z" }, { url = "https://files.pythonhosted.org/packages/5a/00/32d30dee8389dc36d42170a9c66217757289e2afb0de59a3565260f38373/greenlet-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45abe8eb6339518180d5a7fa47fa01945414d7cca5ecb745346fc6a87d2750be", size = 1619472, upload-time = "2026-02-20T20:21:07.966Z" }, @@ -2131,7 +2129,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" }, { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" }, { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" }, - { url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" }, { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" }, { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" }, { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" }, @@ -2140,7 +2137,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" }, { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" }, { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" }, - { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" }, { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" }, { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" }, { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" }, @@ -2149,7 +2145,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, - { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" }, { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, @@ -2158,7 +2153,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, - { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" }, { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, @@ -2507,11 +2501,11 @@ wheels = [ [[package]] name = "identify" -version = "2.6.16" +version = "2.6.17" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5b/8d/e8b97e6bd3fb6fb271346f7981362f1e04d6a7463abd0de79e1fda17c067/identify-2.6.16.tar.gz", hash = "sha256:846857203b5511bbe94d5a352a48ef2359532bc8f6727b5544077a0dcfb24980", size = 99360, upload-time = "2026-01-12T18:58:58.201Z" } +sdist = { url = "https://files.pythonhosted.org/packages/57/84/376a3b96e5a8d33a7aa2c5b3b31a4b3c364117184bf0b17418055f6ace66/identify-2.6.17.tar.gz", hash = "sha256:f816b0b596b204c9fdf076ded172322f2723cf958d02f9c3587504834c8ff04d", size = 99579, upload-time = "2026-03-01T20:04:12.702Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/58/40fbbcefeda82364720eba5cf2270f98496bdfa19ea75b4cccae79c698e6/identify-2.6.16-py2.py3-none-any.whl", hash = "sha256:391ee4d77741d994189522896270b787aed8670389bfd60f326d677d64a6dfb0", size = 99202, upload-time = "2026-01-12T18:58:56.627Z" }, + { url = "https://files.pythonhosted.org/packages/40/66/71c1227dff78aaeb942fed29dd5651f2aec166cc7c9aeea3e8b26a539b7d/identify-2.6.17-py2.py3-none-any.whl", hash = "sha256:be5f8412d5ed4b20f2bd41a65f920990bdccaa6a4a18a08f1eefdcd0bdd885f0", size = 99382, upload-time = "2026-03-01T20:04:11.439Z" }, ] [[package]] @@ -4749,7 +4743,7 @@ docs = [ { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "sphinx-autodoc-typehints", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "sphinx-autodoc-typehints", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "sphinx-autodoc-typehints", version = "3.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sphinx-autodoc-typehints", version = "3.9.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "sphinx-markdown-builder" }, { name = "sphinx-rtd-theme" }, { name = "toml" }, @@ -4964,7 +4958,7 @@ wheels = [ [[package]] name = "posthog" -version = "7.9.4" +version = "7.9.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backoff" }, @@ -4974,9 +4968,9 @@ dependencies = [ { name = "six" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/50/5c0d9232118fdc1434c1b7bbc1a14de5b310498ede09a7e2123ae1f5f8bd/posthog-7.9.4.tar.gz", hash = "sha256:50acc94ef6267d7030575d2ff54e89e748fac2e98525ac672aeb0423160f77cf", size = 172973, upload-time = "2026-02-25T15:28:47.065Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/1b/92ec2f7e598a969d3f58cad96c187fbf3d1b38b4b0d1e05c403054553dae/posthog-7.9.6.tar.gz", hash = "sha256:4e0ecb63885ce522d6c7ad4593871771995931764ae83914c364db0ad5de2bbf", size = 175454, upload-time = "2026-03-02T21:29:01.729Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/6f/794a4e94e3640282e75013ce18e65f0a01afc8d71f733664b4a272f98bce/posthog-7.9.4-py3-none-any.whl", hash = "sha256:414125ddd7a48b9c67feb24d723df1f666af41ad10f8a9a8bbaf5e3b536a2e26", size = 198651, upload-time = "2026-02-25T15:28:45.398Z" }, + { url = "https://files.pythonhosted.org/packages/27/5b/3ece09ecbbbfb2f783e510b54d7170c1322a93bd404aa9b923a84827b5fa/posthog-7.9.6-py3-none-any.whl", hash = "sha256:b1ceda033c9a6660c5d21e2b1c0b4113aaa0969ff02914bf23942c99f602b0f7", size = 201145, upload-time = "2026-03-02T21:29:00.136Z" }, ] [[package]] @@ -5656,11 +5650,11 @@ wheels = [ [[package]] name = "python-dotenv" -version = "1.2.1" +version = "1.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] [[package]] @@ -6540,15 +6534,15 @@ wheels = [ [[package]] name = "sentry-sdk" -version = "2.53.0" +version = "2.54.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d3/06/66c8b705179bc54087845f28fd1b72f83751b6e9a195628e2e9af9926505/sentry_sdk-2.53.0.tar.gz", hash = "sha256:6520ef2c4acd823f28efc55e43eb6ce2e6d9f954a95a3aa96b6fd14871e92b77", size = 412369, upload-time = "2026-02-16T11:11:14.743Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/e9/2e3a46c304e7fa21eaa70612f60354e32699c7102eb961f67448e222ad7c/sentry_sdk-2.54.0.tar.gz", hash = "sha256:2620c2575128d009b11b20f7feb81e4e4e8ae08ec1d36cbc845705060b45cc1b", size = 413813, upload-time = "2026-03-02T15:12:41.355Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/47/d4/2fdf854bc3b9c7f55219678f812600a20a138af2dd847d99004994eada8f/sentry_sdk-2.53.0-py2.py3-none-any.whl", hash = "sha256:46e1ed8d84355ae54406c924f6b290c3d61f4048625989a723fd622aab838899", size = 437908, upload-time = "2026-02-16T11:11:13.227Z" }, + { url = "https://files.pythonhosted.org/packages/53/39/be412cc86bc6247b8f69e9383d7950711bd86f8d0a4a4b0fe8fad685bc21/sentry_sdk-2.54.0-py2.py3-none-any.whl", hash = "sha256:fd74e0e281dcda63afff095d23ebcd6e97006102cdc8e78a29f19ecdf796a0de", size = 439198, upload-time = "2026-03-02T15:12:39.546Z" }, ] [[package]] @@ -6920,7 +6914,7 @@ wheels = [ [[package]] name = "sphinx-autodoc-typehints" -version = "3.8.0" +version = "3.9.5" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.14'", @@ -6930,9 +6924,9 @@ resolution-markers = [ dependencies = [ { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ba/89/72f96fe27aa1cfdc882aa6e1309a86b94e4653c1e8acf9b143d34e89c619/sphinx_autodoc_typehints-3.8.0.tar.gz", hash = "sha256:155a30407e88ed3287eeeb1e9156b0ed0ad08c998b0391c652b540563132fd70", size = 59672, upload-time = "2026-02-25T15:00:35.909Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/ec/21bd9babcfeb9930a73011257002d5cfa5fd30667b8de6d76dbaf8275dfb/sphinx_autodoc_typehints-3.9.5.tar.gz", hash = "sha256:60e646efb7c352a0e98f34dd7fdcde4527fbbdbdf30371ff8321b6b3eb1fd37d", size = 63249, upload-time = "2026-03-02T19:58:07.974Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/0e/36820830c766647d688dfc2b3fda76d76c1cf007eea58fffc1990195aca4/sphinx_autodoc_typehints-3.8.0-py3-none-any.whl", hash = "sha256:f348971f3d88eaee053668b61512e921086b8f0600f1e0887a39bc9476aca51c", size = 32616, upload-time = "2026-02-25T15:00:34.749Z" }, + { url = "https://files.pythonhosted.org/packages/7f/cb/80c250f47a0ca5ac67d82f14811b4068a551a12b4790b085ffdb900de427/sphinx_autodoc_typehints-3.9.5-py3-none-any.whl", hash = "sha256:c94f88a90b6c61a7a6686cb77b410e46e077712838387e6cf22d69e85cfd06a5", size = 34763, upload-time = "2026-03-02T19:58:06.028Z" }, ] [[package]] @@ -7039,62 +7033,62 @@ wheels = [ [[package]] name = "sqlalchemy" -version = "2.0.47" +version = "2.0.48" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cd/4b/1e00561093fe2cd8eef09d406da003c8a118ff02d6548498c1ae677d68d9/sqlalchemy-2.0.47.tar.gz", hash = "sha256:e3e7feb57b267fe897e492b9721ae46d5c7de6f9e8dee58aacf105dc4e154f3d", size = 9886323, upload-time = "2026-02-24T16:34:27.947Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/73/b4a9737255583b5fa858e0bb8e116eb94b88c910164ed2ed719147bde3de/sqlalchemy-2.0.48.tar.gz", hash = "sha256:5ca74f37f3369b45e1f6b7b06afb182af1fd5dde009e4ffd831830d98cbe5fe7", size = 9886075, upload-time = "2026-03-02T15:28:51.474Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/75/17db77c57129c223c7d98518ad1e1faa24ee350c22a44b55390d8463c28c/sqlalchemy-2.0.47-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:33a917ede39406ddb93c3e642b5bc480be7c5fd0f3d0d6ae1036d466fb963f1a", size = 2157331, upload-time = "2026-02-24T16:43:52.693Z" }, - { url = "https://files.pythonhosted.org/packages/b0/d6/3658f7e5c376de774c009f2bb9c0ddf88a35b89c5bfb15ee7174a17b1a5f/sqlalchemy-2.0.47-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:561d027c829b01e040bdade6b6f5b429249d056ef95d7bdcb9211539ecc82803", size = 3236939, upload-time = "2026-02-24T17:28:57.419Z" }, - { url = "https://files.pythonhosted.org/packages/4e/38/f4b94f85d1c26cb9ee0e57449754de816c326f9586b9a8c5247eb49146de/sqlalchemy-2.0.47-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fa5072a37e68c565363c009b7afa5b199b488c87940ec02719860093a08f34ca", size = 3235190, upload-time = "2026-02-24T17:27:07.884Z" }, - { url = "https://files.pythonhosted.org/packages/94/f2/36714f1de01e135a2bf142b662e416e5338ab63c47878e31051338c66e2d/sqlalchemy-2.0.47-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1e7ed17dd4312a298b6024bfd1baf51654bc49e3f03c798005babf0c7922d6a7", size = 3188064, upload-time = "2026-02-24T17:28:58.908Z" }, - { url = "https://files.pythonhosted.org/packages/ab/94/fcd978e7625cd1c97d9f1d7363e18e37d24314e572acd7c091e3a4210106/sqlalchemy-2.0.47-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6992e353fcb0593eb42d95ad84b3e58fe40b5e37fd332b9ccba28f4b2f36d1fc", size = 3209480, upload-time = "2026-02-24T17:27:09.823Z" }, - { url = "https://files.pythonhosted.org/packages/23/29/c633202b9900ab65f0162f59df737b57f30010f44d892b186810c9ed58b7/sqlalchemy-2.0.47-cp310-cp310-win32.whl", hash = "sha256:05a6d58ed99ebd01303c92d29a0c9cbf70f637b3ddd155f5172c5a7239940998", size = 2117652, upload-time = "2026-02-24T17:14:34.635Z" }, - { url = "https://files.pythonhosted.org/packages/00/39/54acf13913932b8508058d47a169e6fcde9adaa4cbfa16cbf30da1f6a482/sqlalchemy-2.0.47-cp310-cp310-win_amd64.whl", hash = "sha256:4a7aa4a584cc97e268c11e700dea0b763874eaebb435e75e7d0ffee5d90f5030", size = 2140883, upload-time = "2026-02-24T17:14:35.875Z" }, - { url = "https://files.pythonhosted.org/packages/94/13/886338d3e8ab5ddcfe84d54302c749b1793e16c4bba63d7004e3f7baa8ec/sqlalchemy-2.0.47-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3a1dbf0913879c443617d6b64403cf2801c941651db8c60e96d204ed9388d6b0", size = 2157124, upload-time = "2026-02-24T16:43:54.706Z" }, - { url = "https://files.pythonhosted.org/packages/b6/bb/a897f6a66c9986aa9f27f5cf8550637d8a5ea368fd7fb42f6dac3105b4dc/sqlalchemy-2.0.47-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:775effbb97ea3b00c4dd3aeaf3ba8acba6e3e2b4b41d17d67a27e696843dbc95", size = 3313513, upload-time = "2026-02-24T17:29:00.527Z" }, - { url = "https://files.pythonhosted.org/packages/59/fb/69bfae022b681507565ab0d34f0c80aa1e9f954a5a7cbfb0ed054966ac8d/sqlalchemy-2.0.47-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56cc834a3ffac34270cc2a41875e0f40e97aa651f4f3ca1cfbbf421c044cb62b", size = 3313014, upload-time = "2026-02-24T17:27:11.679Z" }, - { url = "https://files.pythonhosted.org/packages/04/f3/0eba329f7c182d53205a228c4fd24651b95489b431ea2bd830887b4c13c4/sqlalchemy-2.0.47-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:49b5e0c7244262f39e767c018e4fdb5e5dbc23cd54c5ddac8eea8f0ba32ef890", size = 3265389, upload-time = "2026-02-24T17:29:02.497Z" }, - { url = "https://files.pythonhosted.org/packages/5c/06/654edc084b3b46ac79e04200d7c46467ae80c759c4ee41c897f9272b036f/sqlalchemy-2.0.47-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:15cd822a3f1f6f77b5b841a30c1a07a07f7dee3385f17e638e1722de9ab683be", size = 3287604, upload-time = "2026-02-24T17:27:13.295Z" }, - { url = "https://files.pythonhosted.org/packages/78/33/c18c8f63b61981219d3aa12321bb7ccee605034d195e868ed94f9727b27c/sqlalchemy-2.0.47-cp311-cp311-win32.whl", hash = "sha256:9847a19548cd283a65e1ce0afd54016598d55ff72682d6fd3e493af6fc044064", size = 2116916, upload-time = "2026-02-24T17:14:37.392Z" }, - { url = "https://files.pythonhosted.org/packages/f5/c6/a59e3f9796fff844e16afbd821db9abfd6e12698db9441a231a96193a100/sqlalchemy-2.0.47-cp311-cp311-win_amd64.whl", hash = "sha256:722abf1c82aeca46a1a0803711244a48a298279eeaec9e02f7bfee9e064182e5", size = 2141587, upload-time = "2026-02-24T17:14:39.746Z" }, - { url = "https://files.pythonhosted.org/packages/80/88/74eb470223ff88ea6572a132c0b8de8c1d8ed7b843d3b44a8a3c77f31d39/sqlalchemy-2.0.47-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4fa91b19d6b9821c04cc8f7aa2476429cc8887b9687c762815aa629f5c0edec1", size = 2155687, upload-time = "2026-02-24T17:05:46.451Z" }, - { url = "https://files.pythonhosted.org/packages/ef/ba/1447d3d558971b036cb93b557595cb5dcdfe728f1c7ac4dec16505ef5756/sqlalchemy-2.0.47-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c5bbbd14eff577c8c79cbfe39a0771eecd20f430f3678533476f0087138f356", size = 3336978, upload-time = "2026-02-24T17:18:04.597Z" }, - { url = "https://files.pythonhosted.org/packages/8a/07/b47472d2ffd0776826f17ccf0b4d01b224c99fbd1904aeb103dffbb4b1cc/sqlalchemy-2.0.47-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5a6c555da8d4280a3c4c78c5b7a3f990cee2b2884e5f934f87a226191682ff7", size = 3349939, upload-time = "2026-02-24T17:27:18.937Z" }, - { url = "https://files.pythonhosted.org/packages/bb/c6/95fa32b79b57769da3e16f054cf658d90940317b5ca0ec20eac84aa19c4f/sqlalchemy-2.0.47-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ed48a1701d24dff3bb49a5bce94d6bc84cbe33d98af2aa2d3cdcce3dea1709ec", size = 3279648, upload-time = "2026-02-24T17:18:07.038Z" }, - { url = "https://files.pythonhosted.org/packages/bb/c8/3d07e7c73928dc59a0bed40961ca4e313e797bce650b088e8d5fdd3ad939/sqlalchemy-2.0.47-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4f3178c920ad98158f0b6309382194df04b14808fa6052ae07099fdde29d5602", size = 3314695, upload-time = "2026-02-24T17:27:20.93Z" }, - { url = "https://files.pythonhosted.org/packages/6b/d2/ed32b1611c1e19fdb028eee1adc5a9aa138c2952d09ae11f1670170f80ae/sqlalchemy-2.0.47-cp312-cp312-win32.whl", hash = "sha256:b9c11ac9934dd59ece9619fe42780a08abe2faab7b0543bb00d5eabea4f421b9", size = 2115502, upload-time = "2026-02-24T17:22:52.546Z" }, - { url = "https://files.pythonhosted.org/packages/fd/52/9de590356a4dd8e9ef5a881dbba64b2bbc4cbc71bf02bc68e775fb9b1899/sqlalchemy-2.0.47-cp312-cp312-win_amd64.whl", hash = "sha256:db43b72cf8274a99e089755c9c1e0b947159b71adbc2c83c3de2e38d5d607acb", size = 2142435, upload-time = "2026-02-24T17:22:54.268Z" }, - { url = "https://files.pythonhosted.org/packages/4a/e5/0af64ce7d8f60ec5328c10084e2f449e7912a9b8bdbefdcfb44454a25f49/sqlalchemy-2.0.47-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:456a135b790da5d3c6b53d0ef71ac7b7d280b7f41eb0c438986352bf03ca7143", size = 2152551, upload-time = "2026-02-24T17:05:47.675Z" }, - { url = "https://files.pythonhosted.org/packages/63/79/746b8d15f6940e2ac469ce22d7aa5b1124b1ab820bad9b046eb3000c88a6/sqlalchemy-2.0.47-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09a2f7698e44b3135433387da5d8846cf7cc7c10e5425af7c05fee609df978b6", size = 3278782, upload-time = "2026-02-24T17:18:10.012Z" }, - { url = "https://files.pythonhosted.org/packages/91/b1/bd793ddb34345d1ed43b13ab2d88c95d7d4eb2e28f5b5a99128b9cc2bca2/sqlalchemy-2.0.47-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0bbc72e6a177c78d724f9106aaddc0d26a2ada89c6332b5935414eccf04cbd5", size = 3295155, upload-time = "2026-02-24T17:27:22.827Z" }, - { url = "https://files.pythonhosted.org/packages/97/84/7213def33f94e5ca6f5718d259bc9f29de0363134648425aa218d4356b23/sqlalchemy-2.0.47-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:75460456b043b78b6006e41bdf5b86747ee42eafaf7fffa3b24a6e9a456a2092", size = 3226834, upload-time = "2026-02-24T17:18:11.465Z" }, - { url = "https://files.pythonhosted.org/packages/ef/06/456810204f4dc29b5f025b1b0a03b4bd6b600ebf3c1040aebd90a257fa33/sqlalchemy-2.0.47-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5d9adaa616c3bc7d80f9ded57cd84b51d6617cad6a5456621d858c9f23aaee01", size = 3265001, upload-time = "2026-02-24T17:27:24.813Z" }, - { url = "https://files.pythonhosted.org/packages/fb/20/df3920a4b2217dbd7390a5bd277c1902e0393f42baaf49f49b3c935e7328/sqlalchemy-2.0.47-cp313-cp313-win32.whl", hash = "sha256:76e09f974382a496a5ed985db9343628b1cb1ac911f27342e4cc46a8bac10476", size = 2113647, upload-time = "2026-02-24T17:22:55.747Z" }, - { url = "https://files.pythonhosted.org/packages/46/06/7873ddf69918efbfabd7211829f4bd8019739d0a719253112d305d3ba51d/sqlalchemy-2.0.47-cp313-cp313-win_amd64.whl", hash = "sha256:0664089b0bf6724a0bfb49a0cf4d4da24868a0a5c8e937cd7db356d5dcdf2c66", size = 2139425, upload-time = "2026-02-24T17:22:57.033Z" }, - { url = "https://files.pythonhosted.org/packages/54/fa/61ad9731370c90ac7ea5bf8f5eaa12c48bb4beec41c0fa0360becf4ac10d/sqlalchemy-2.0.47-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ed0c967c701ae13da98eb220f9ddab3044ab63504c1ba24ad6a59b26826ad003", size = 3558809, upload-time = "2026-02-24T17:12:15.232Z" }, - { url = "https://files.pythonhosted.org/packages/33/d5/221fac96f0529391fe374875633804c866f2b21a9c6d3a6ca57d9c12cfd7/sqlalchemy-2.0.47-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3537943a61fd25b241e976426a0c6814434b93cf9b09d39e8e78f3c9eb9a487", size = 3525480, upload-time = "2026-02-24T17:27:59.602Z" }, - { url = "https://files.pythonhosted.org/packages/ec/55/8247d53998c3673e4a8d1958eba75c6f5cc3b39082029d400bb1f2a911ae/sqlalchemy-2.0.47-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:57f7e336a64a0dba686c66392d46b9bc7af2c57d55ce6dc1697b4ef32b043ceb", size = 3466569, upload-time = "2026-02-24T17:12:16.94Z" }, - { url = "https://files.pythonhosted.org/packages/6b/b5/c1f0eea1bac6790845f71420a7fe2f2a0566203aa57543117d4af3b77d1c/sqlalchemy-2.0.47-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dff735a621858680217cb5142b779bad40ef7322ddbb7c12062190db6879772e", size = 3475770, upload-time = "2026-02-24T17:28:02.034Z" }, - { url = "https://files.pythonhosted.org/packages/c5/ed/2f43f92474ea0c43c204657dc47d9d002cd738b96ca2af8e6d29a9b5e42d/sqlalchemy-2.0.47-cp313-cp313t-win32.whl", hash = "sha256:3893dc096bb3cca9608ea3487372ffcea3ae9b162f40e4d3c51dd49db1d1b2dc", size = 2141300, upload-time = "2026-02-24T17:14:37.024Z" }, - { url = "https://files.pythonhosted.org/packages/cc/a9/8b73f9f1695b6e92f7aaf1711135a1e3bbeb78bca9eded35cb79180d3c6d/sqlalchemy-2.0.47-cp313-cp313t-win_amd64.whl", hash = "sha256:b5103427466f4b3e61f04833ae01f9a914b1280a2a8bcde3a9d7ab11f3755b42", size = 2173053, upload-time = "2026-02-24T17:14:38.688Z" }, - { url = "https://files.pythonhosted.org/packages/c1/30/98243209aae58ed80e090ea988d5182244ca7ab3ff59e6d850c3dfc7651e/sqlalchemy-2.0.47-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b03010a5a5dfe71676bc83f2473ebe082478e32d77e6f082c8fe15a31c3b42a6", size = 2154355, upload-time = "2026-02-24T17:05:48.959Z" }, - { url = "https://files.pythonhosted.org/packages/ab/62/12ca6ea92055fe486d6558a2a4efe93e194ff597463849c01f88e5adb99d/sqlalchemy-2.0.47-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8e3371aa9024520883a415a09cc20c33cfd3eeccf9e0f4f4c367f940b9cbd44", size = 3274486, upload-time = "2026-02-24T17:18:13.659Z" }, - { url = "https://files.pythonhosted.org/packages/97/88/7dfbdeaa8d42b1584e65d6cc713e9d33b6fa563e0d546d5cb87e545bb0e5/sqlalchemy-2.0.47-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9449f747e50d518c6e1b40cc379e48bfc796453c47b15e627ea901c201e48a6", size = 3279481, upload-time = "2026-02-24T17:27:26.491Z" }, - { url = "https://files.pythonhosted.org/packages/d0/b7/75e1c1970616a9dd64a8a6fd788248da2ddaf81c95f4875f2a1e8aee4128/sqlalchemy-2.0.47-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:21410f60d5cac1d6bfe360e05bd91b179be4fa0aa6eea6be46054971d277608f", size = 3224269, upload-time = "2026-02-24T17:18:15.078Z" }, - { url = "https://files.pythonhosted.org/packages/31/ac/eec1a13b891df9a8bc203334caf6e6aac60b02f61b018ef3b4124b8c4120/sqlalchemy-2.0.47-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:819841dd5bb4324c284c09e2874cf96fe6338bfb57a64548d9b81a4e39c9871f", size = 3246262, upload-time = "2026-02-24T17:27:27.986Z" }, - { url = "https://files.pythonhosted.org/packages/c9/b0/661b0245b06421058610da39f8ceb34abcc90b49f90f256380968d761dbe/sqlalchemy-2.0.47-cp314-cp314-win32.whl", hash = "sha256:e255ee44821a7ef45649c43064cf94e74f81f61b4df70547304b97a351e9b7db", size = 2116528, upload-time = "2026-02-24T17:22:59.363Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ef/1035a90d899e61810791c052004958be622a2cf3eb3df71c3fe20778c5d0/sqlalchemy-2.0.47-cp314-cp314-win_amd64.whl", hash = "sha256:209467ff73ea1518fe1a5aaed9ba75bb9e33b2666e2553af9ccd13387bf192cb", size = 2142181, upload-time = "2026-02-24T17:23:01.001Z" }, - { url = "https://files.pythonhosted.org/packages/76/bb/17a1dd09cbba91258218ceb582225f14b5364d2683f9f5a274f72f2d764f/sqlalchemy-2.0.47-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e78fd9186946afaa287f8a1fe147ead06e5d566b08c0afcb601226e9c7322a64", size = 3563477, upload-time = "2026-02-24T17:12:18.46Z" }, - { url = "https://files.pythonhosted.org/packages/66/8f/1a03d24c40cc321ef2f2231f05420d140bb06a84f7047eaa7eaa21d230ba/sqlalchemy-2.0.47-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5740e2f31b5987ed9619d6912ae5b750c03637f2078850da3002934c9532f172", size = 3528568, upload-time = "2026-02-24T17:28:03.732Z" }, - { url = "https://files.pythonhosted.org/packages/fd/53/d56a213055d6b038a5384f0db5ece7343334aca230ff3f0fa1561106f22c/sqlalchemy-2.0.47-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb9ac00d03de93acb210e8ec7243fefe3e012515bf5fd2f0898c8dff38bc77a4", size = 3472284, upload-time = "2026-02-24T17:12:20.319Z" }, - { url = "https://files.pythonhosted.org/packages/ff/19/c235d81b9cfdd6130bf63143b7bade0dc4afa46c4b634d5d6b2a96bea233/sqlalchemy-2.0.47-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c72a0b9eb2672d70d112cb149fbaf172d466bc691014c496aaac594f1988e706", size = 3478410, upload-time = "2026-02-24T17:28:05.892Z" }, - { url = "https://files.pythonhosted.org/packages/0e/db/cafdeca5ecdaa3bb0811ba5449501da677ce0d83be8d05c5822da72d2e86/sqlalchemy-2.0.47-cp314-cp314t-win32.whl", hash = "sha256:c200db1128d72a71dc3c31c24b42eb9fd85b2b3e5a3c9ba1e751c11ac31250ff", size = 2147164, upload-time = "2026-02-24T17:14:40.783Z" }, - { url = "https://files.pythonhosted.org/packages/fc/5e/ff41a010e9e0f76418b02ad352060a4341bb15f0af66cedc924ab376c7c6/sqlalchemy-2.0.47-cp314-cp314t-win_amd64.whl", hash = "sha256:669837759b84e575407355dcff912835892058aea9b80bd1cb76d6a151cf37f7", size = 2182154, upload-time = "2026-02-24T17:14:43.205Z" }, - { url = "https://files.pythonhosted.org/packages/15/9f/7c378406b592fcf1fc157248607b495a40e3202ba4a6f1372a2ba6447717/sqlalchemy-2.0.47-py3-none-any.whl", hash = "sha256:e2647043599297a1ef10e720cf310846b7f31b6c841fee093d2b09d81215eb93", size = 1940159, upload-time = "2026-02-24T17:15:07.158Z" }, + { url = "https://files.pythonhosted.org/packages/9a/67/1235676e93dd3b742a4a8eddfae49eea46c85e3eed29f0da446a8dd57500/sqlalchemy-2.0.48-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7001dc9d5f6bb4deb756d5928eaefe1930f6f4179da3924cbd95ee0e9f4dce89", size = 2157384, upload-time = "2026-03-02T15:38:26.781Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d7/fa728b856daa18c10e1390e76f26f64ac890c947008284387451d56ca3d0/sqlalchemy-2.0.48-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a89ce07ad2d4b8cfc30bd5889ec40613e028ed80ef47da7d9dd2ce969ad30e0", size = 3236981, upload-time = "2026-03-02T15:58:53.53Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ad/6c4395649a212a6c603a72c5b9ab5dce3135a1546cfdffa3c427e71fd535/sqlalchemy-2.0.48-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10853a53a4a00417a00913d270dddda75815fcb80675874285f41051c094d7dd", size = 3235232, upload-time = "2026-03-02T15:52:25.654Z" }, + { url = "https://files.pythonhosted.org/packages/01/f4/58f845e511ac0509765a6f85eb24924c1ef0d54fb50de9d15b28c3601458/sqlalchemy-2.0.48-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fac0fa4e4f55f118fd87177dacb1c6522fe39c28d498d259014020fec9164c29", size = 3188106, upload-time = "2026-03-02T15:58:55.193Z" }, + { url = "https://files.pythonhosted.org/packages/3f/f9/6dcc7bfa5f5794c3a095e78cd1de8269dfb5584dfd4c2c00a50d3c1ade44/sqlalchemy-2.0.48-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3713e21ea67bca727eecd4a24bf68bcd414c403faae4989442be60994301ded0", size = 3209522, upload-time = "2026-03-02T15:52:27.407Z" }, + { url = "https://files.pythonhosted.org/packages/d7/5a/b632875ab35874d42657f079529f0745410604645c269a8c21fb4272ff7a/sqlalchemy-2.0.48-cp310-cp310-win32.whl", hash = "sha256:d404dc897ce10e565d647795861762aa2d06ca3f4a728c5e9a835096c7059018", size = 2117695, upload-time = "2026-03-02T15:46:51.389Z" }, + { url = "https://files.pythonhosted.org/packages/de/03/9752eb2a41afdd8568e41ac3c3128e32a0a73eada5ab80483083604a56d1/sqlalchemy-2.0.48-cp310-cp310-win_amd64.whl", hash = "sha256:841a94c66577661c1f088ac958cd767d7c9bf507698f45afffe7a4017049de76", size = 2140928, upload-time = "2026-03-02T15:46:52.992Z" }, + { url = "https://files.pythonhosted.org/packages/d7/6d/b8b78b5b80f3c3ab3f7fa90faa195ec3401f6d884b60221260fd4d51864c/sqlalchemy-2.0.48-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b4c575df7368b3b13e0cebf01d4679f9a28ed2ae6c1cd0b1d5beffb6b2007dc", size = 2157184, upload-time = "2026-03-02T15:38:28.161Z" }, + { url = "https://files.pythonhosted.org/packages/21/4b/4f3d4a43743ab58b95b9ddf5580a265b593d017693df9e08bd55780af5bb/sqlalchemy-2.0.48-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e83e3f959aaa1c9df95c22c528096d94848a1bc819f5d0ebf7ee3df0ca63db6c", size = 3313555, upload-time = "2026-03-02T15:58:57.21Z" }, + { url = "https://files.pythonhosted.org/packages/21/dd/3b7c53f1dbbf736fd27041aee68f8ac52226b610f914085b1652c2323442/sqlalchemy-2.0.48-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f7b7243850edd0b8b97043f04748f31de50cf426e939def5c16bedb540698f7", size = 3313057, upload-time = "2026-03-02T15:52:29.366Z" }, + { url = "https://files.pythonhosted.org/packages/d9/cc/3e600a90ae64047f33313d7d32e5ad025417f09d2ded487e8284b5e21a15/sqlalchemy-2.0.48-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:82745b03b4043e04600a6b665cb98697c4339b24e34d74b0a2ac0a2488b6f94d", size = 3265431, upload-time = "2026-03-02T15:58:59.096Z" }, + { url = "https://files.pythonhosted.org/packages/8b/19/780138dacfe3f5024f4cf96e4005e91edf6653d53d3673be4844578faf1d/sqlalchemy-2.0.48-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5e088bf43f6ee6fec7dbf1ef7ff7774a616c236b5c0cb3e00662dd71a56b571", size = 3287646, upload-time = "2026-03-02T15:52:31.569Z" }, + { url = "https://files.pythonhosted.org/packages/40/fd/f32ced124f01a23151f4777e4c705f3a470adc7bd241d9f36a7c941a33bf/sqlalchemy-2.0.48-cp311-cp311-win32.whl", hash = "sha256:9c7d0a77e36b5f4b01ca398482230ab792061d243d715299b44a0b55c89fe617", size = 2116956, upload-time = "2026-03-02T15:46:54.535Z" }, + { url = "https://files.pythonhosted.org/packages/58/d5/dd767277f6feef12d05651538f280277e661698f617fa4d086cce6055416/sqlalchemy-2.0.48-cp311-cp311-win_amd64.whl", hash = "sha256:583849c743e0e3c9bb7446f5b5addeacedc168d657a69b418063dfdb2d90081c", size = 2141627, upload-time = "2026-03-02T15:46:55.849Z" }, + { url = "https://files.pythonhosted.org/packages/ef/91/a42ae716f8925e9659df2da21ba941f158686856107a61cc97a95e7647a3/sqlalchemy-2.0.48-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:348174f228b99f33ca1f773e85510e08927620caa59ffe7803b37170df30332b", size = 2155737, upload-time = "2026-03-02T15:49:13.207Z" }, + { url = "https://files.pythonhosted.org/packages/b9/52/f75f516a1f3888f027c1cfb5d22d4376f4b46236f2e8669dcb0cddc60275/sqlalchemy-2.0.48-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53667b5f668991e279d21f94ccfa6e45b4e3f4500e7591ae59a8012d0f010dcb", size = 3337020, upload-time = "2026-03-02T15:50:34.547Z" }, + { url = "https://files.pythonhosted.org/packages/37/9a/0c28b6371e0cdcb14f8f1930778cb3123acfcbd2c95bb9cf6b4a2ba0cce3/sqlalchemy-2.0.48-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34634e196f620c7a61d18d5cf7dc841ca6daa7961aed75d532b7e58b309ac894", size = 3349983, upload-time = "2026-03-02T15:53:25.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/46/0aee8f3ff20b1dcbceb46ca2d87fcc3d48b407925a383ff668218509d132/sqlalchemy-2.0.48-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:546572a1793cc35857a2ffa1fe0e58571af1779bcc1ffa7c9fb0839885ed69a9", size = 3279690, upload-time = "2026-03-02T15:50:36.277Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/a957bc91293b49181350bfd55e6dfc6e30b7f7d83dc6792d72043274a390/sqlalchemy-2.0.48-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:07edba08061bc277bfdc772dd2a1a43978f5a45994dd3ede26391b405c15221e", size = 3314738, upload-time = "2026-03-02T15:53:27.519Z" }, + { url = "https://files.pythonhosted.org/packages/4b/44/1d257d9f9556661e7bdc83667cc414ba210acfc110c82938cb3611eea58f/sqlalchemy-2.0.48-cp312-cp312-win32.whl", hash = "sha256:908a3fa6908716f803b86896a09a2c4dde5f5ce2bb07aacc71ffebb57986ce99", size = 2115546, upload-time = "2026-03-02T15:54:31.591Z" }, + { url = "https://files.pythonhosted.org/packages/f2/af/c3c7e1f3a2b383155a16454df62ae8c62a30dd238e42e68c24cebebbfae6/sqlalchemy-2.0.48-cp312-cp312-win_amd64.whl", hash = "sha256:68549c403f79a8e25984376480959975212a670405e3913830614432b5daa07a", size = 2142484, upload-time = "2026-03-02T15:54:34.072Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c6/569dc8bf3cd375abc5907e82235923e986799f301cd79a903f784b996fca/sqlalchemy-2.0.48-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e3070c03701037aa418b55d36532ecb8f8446ed0135acb71c678dbdf12f5b6e4", size = 2152599, upload-time = "2026-03-02T15:49:14.41Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ff/f4e04a4bd5a24304f38cb0d4aa2ad4c0fb34999f8b884c656535e1b2b74c/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2645b7d8a738763b664a12a1542c89c940daa55196e8d73e55b169cc5c99f65f", size = 3278825, upload-time = "2026-03-02T15:50:38.269Z" }, + { url = "https://files.pythonhosted.org/packages/fe/88/cb59509e4668d8001818d7355d9995be90c321313078c912420603a7cb95/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b19151e76620a412c2ac1c6f977ab1b9fa7ad43140178345136456d5265b32ed", size = 3295200, upload-time = "2026-03-02T15:53:29.366Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/1609a4442aefd750ea2f32629559394ec92e89ac1d621a7f462b70f736ff/sqlalchemy-2.0.48-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5b193a7e29fd9fa56e502920dca47dffe60f97c863494946bd698c6058a55658", size = 3226876, upload-time = "2026-03-02T15:50:39.802Z" }, + { url = "https://files.pythonhosted.org/packages/37/c3/6ae2ab5ea2fa989fbac4e674de01224b7a9d744becaf59bb967d62e99bed/sqlalchemy-2.0.48-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:36ac4ddc3d33e852da9cb00ffb08cea62ca05c39711dc67062ca2bb1fae35fd8", size = 3265045, upload-time = "2026-03-02T15:53:31.421Z" }, + { url = "https://files.pythonhosted.org/packages/6f/82/ea4665d1bb98c50c19666e672f21b81356bd6077c4574e3d2bbb84541f53/sqlalchemy-2.0.48-cp313-cp313-win32.whl", hash = "sha256:389b984139278f97757ea9b08993e7b9d1142912e046ab7d82b3fbaeb0209131", size = 2113700, upload-time = "2026-03-02T15:54:35.825Z" }, + { url = "https://files.pythonhosted.org/packages/b7/2b/b9040bec58c58225f073f5b0c1870defe1940835549dafec680cbd58c3c3/sqlalchemy-2.0.48-cp313-cp313-win_amd64.whl", hash = "sha256:d612c976cbc2d17edfcc4c006874b764e85e990c29ce9bd411f926bbfb02b9a2", size = 2139487, upload-time = "2026-03-02T15:54:37.079Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/7b17bd50244b78a49d22cc63c969d71dc4de54567dc152a9b46f6fae40ce/sqlalchemy-2.0.48-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69f5bc24904d3bc3640961cddd2523e361257ef68585d6e364166dfbe8c78fae", size = 3558851, upload-time = "2026-03-02T15:57:48.607Z" }, + { url = "https://files.pythonhosted.org/packages/20/0d/213668e9aca61d370f7d2a6449ea4ec699747fac67d4bda1bb3d129025be/sqlalchemy-2.0.48-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd08b90d211c086181caed76931ecfa2bdfc83eea3cfccdb0f82abc6c4b876cb", size = 3525525, upload-time = "2026-03-02T16:04:38.058Z" }, + { url = "https://files.pythonhosted.org/packages/85/d7/a84edf412979e7d59c69b89a5871f90a49228360594680e667cb2c46a828/sqlalchemy-2.0.48-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1ccd42229aaac2df431562117ac7e667d702e8e44afdb6cf0e50fa3f18160f0b", size = 3466611, upload-time = "2026-03-02T15:57:50.759Z" }, + { url = "https://files.pythonhosted.org/packages/86/55/42404ce5770f6be26a2b0607e7866c31b9a4176c819e9a7a5e0a055770be/sqlalchemy-2.0.48-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0dcbc588cd5b725162c076eb9119342f6579c7f7f55057bb7e3c6ff27e13121", size = 3475812, upload-time = "2026-03-02T16:04:40.092Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ae/29b87775fadc43e627cf582fe3bda4d02e300f6b8f2747c764950d13784c/sqlalchemy-2.0.48-cp313-cp313t-win32.whl", hash = "sha256:9764014ef5e58aab76220c5664abb5d47d5bc858d9debf821e55cfdd0f128485", size = 2141335, upload-time = "2026-03-02T15:52:51.518Z" }, + { url = "https://files.pythonhosted.org/packages/91/44/f39d063c90f2443e5b46ec4819abd3d8de653893aae92df42a5c4f5843de/sqlalchemy-2.0.48-cp313-cp313t-win_amd64.whl", hash = "sha256:e2f35b4cccd9ed286ad62e0a3c3ac21e06c02abc60e20aa51a3e305a30f5fa79", size = 2173095, upload-time = "2026-03-02T15:52:52.79Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b3/f437eaa1cf028bb3c927172c7272366393e73ccd104dcf5b6963f4ab5318/sqlalchemy-2.0.48-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e2d0d88686e3d35a76f3e15a34e8c12d73fc94c1dea1cd55782e695cc14086dd", size = 2154401, upload-time = "2026-03-02T15:49:17.24Z" }, + { url = "https://files.pythonhosted.org/packages/6c/1c/b3abdf0f402aa3f60f0df6ea53d92a162b458fca2321d8f1f00278506402/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49b7bddc1eebf011ea5ab722fdbe67a401caa34a350d278cc7733c0e88fecb1f", size = 3274528, upload-time = "2026-03-02T15:50:41.489Z" }, + { url = "https://files.pythonhosted.org/packages/f2/5e/327428a034407651a048f5e624361adf3f9fbac9d0fa98e981e9c6ff2f5e/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:426c5ca86415d9b8945c7073597e10de9644802e2ff502b8e1f11a7a2642856b", size = 3279523, upload-time = "2026-03-02T15:53:32.962Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ca/ece73c81a918add0965b76b868b7b5359e068380b90ef1656ee995940c02/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:288937433bd44e3990e7da2402fabc44a3c6c25d3704da066b85b89a85474ae0", size = 3224312, upload-time = "2026-03-02T15:50:42.996Z" }, + { url = "https://files.pythonhosted.org/packages/88/11/fbaf1ae91fa4ee43f4fe79661cead6358644824419c26adb004941bdce7c/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8183dc57ae7d9edc1346e007e840a9f3d6aa7b7f165203a99e16f447150140d2", size = 3246304, upload-time = "2026-03-02T15:53:34.937Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5fb0deb13930b4f2f698c5541ae076c18981173e27dd00376dbaea7a9c82/sqlalchemy-2.0.48-cp314-cp314-win32.whl", hash = "sha256:1182437cb2d97988cfea04cf6cdc0b0bb9c74f4d56ec3d08b81e23d621a28cc6", size = 2116565, upload-time = "2026-03-02T15:54:38.321Z" }, + { url = "https://files.pythonhosted.org/packages/95/7e/e83615cb63f80047f18e61e31e8e32257d39458426c23006deeaf48f463b/sqlalchemy-2.0.48-cp314-cp314-win_amd64.whl", hash = "sha256:144921da96c08feb9e2b052c5c5c1d0d151a292c6135623c6b2c041f2a45f9e0", size = 2142205, upload-time = "2026-03-02T15:54:39.831Z" }, + { url = "https://files.pythonhosted.org/packages/83/e3/69d8711b3f2c5135e9cde5f063bc1605860f0b2c53086d40c04017eb1f77/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5aee45fd2c6c0f2b9cdddf48c48535e7471e42d6fb81adfde801da0bd5b93241", size = 3563519, upload-time = "2026-03-02T15:57:52.387Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4f/a7cce98facca73c149ea4578981594aaa5fd841e956834931de503359336/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7cddca31edf8b0653090cbb54562ca027c421c58ddde2c0685f49ff56a1690e0", size = 3528611, upload-time = "2026-03-02T16:04:42.097Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7d/5936c7a03a0b0cb0fa0cc425998821c6029756b0855a8f7ee70fba1de955/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7a936f1bb23d370b7c8cc079d5fce4c7d18da87a33c6744e51a93b0f9e97e9b3", size = 3472326, upload-time = "2026-03-02T15:57:54.423Z" }, + { url = "https://files.pythonhosted.org/packages/f4/33/cea7dfc31b52904efe3dcdc169eb4514078887dff1f5ae28a7f4c5d54b3c/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e004aa9248e8cb0a5f9b96d003ca7c1c0a5da8decd1066e7b53f59eb8ce7c62b", size = 3478453, upload-time = "2026-03-02T16:04:44.584Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/32107c4d13be077a9cae61e9ae49966a35dc4bf442a8852dd871db31f62e/sqlalchemy-2.0.48-cp314-cp314t-win32.whl", hash = "sha256:b8438ec5594980d405251451c5b7ea9aa58dda38eb7ac35fb7e4c696712ee24f", size = 2147209, upload-time = "2026-03-02T15:52:54.274Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d7/1e073da7a4bc645eb83c76067284a0374e643bc4be57f14cc6414656f92c/sqlalchemy-2.0.48-cp314-cp314t-win_amd64.whl", hash = "sha256:d854b3970067297f3a7fbd7a4683587134aa9b3877ee15aa29eea478dc68f933", size = 2182198, upload-time = "2026-03-02T15:52:55.606Z" }, + { url = "https://files.pythonhosted.org/packages/46/2c/9664130905f03db57961b8980b05cab624afd114bf2be2576628a9f22da4/sqlalchemy-2.0.48-py3-none-any.whl", hash = "sha256:a66fe406437dd65cacd96a72689a3aaaecaebbcd62d81c5ac1c0fdbeac835096", size = 1940202, upload-time = "2026-03-02T15:52:43.285Z" }, ] [[package]] @@ -8253,128 +8247,142 @@ wheels = [ [[package]] name = "yarl" -version = "1.22.0" +version = "1.23.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "multidict" }, { name = "propcache" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" } +sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/43/a2204825342f37c337f5edb6637040fa14e365b2fcc2346960201d457579/yarl-1.22.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c7bd6683587567e5a49ee6e336e0612bec8329be1b7d4c8af5687dcdeb67ee1e", size = 140517, upload-time = "2025-10-06T14:08:42.494Z" }, - { url = "https://files.pythonhosted.org/packages/44/6f/674f3e6f02266428c56f704cd2501c22f78e8b2eeb23f153117cc86fb28a/yarl-1.22.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5cdac20da754f3a723cceea5b3448e1a2074866406adeb4ef35b469d089adb8f", size = 93495, upload-time = "2025-10-06T14:08:46.2Z" }, - { url = "https://files.pythonhosted.org/packages/b8/12/5b274d8a0f30c07b91b2f02cba69152600b47830fcfb465c108880fcee9c/yarl-1.22.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:07a524d84df0c10f41e3ee918846e1974aba4ec017f990dc735aad487a0bdfdf", size = 94400, upload-time = "2025-10-06T14:08:47.855Z" }, - { url = "https://files.pythonhosted.org/packages/e2/7f/df1b6949b1fa1aa9ff6de6e2631876ad4b73c4437822026e85d8acb56bb1/yarl-1.22.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1b329cb8146d7b736677a2440e422eadd775d1806a81db2d4cded80a48efc1a", size = 347545, upload-time = "2025-10-06T14:08:49.683Z" }, - { url = "https://files.pythonhosted.org/packages/84/09/f92ed93bd6cd77872ab6c3462df45ca45cd058d8f1d0c9b4f54c1704429f/yarl-1.22.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:75976c6945d85dbb9ee6308cd7ff7b1fb9409380c82d6119bd778d8fcfe2931c", size = 319598, upload-time = "2025-10-06T14:08:51.215Z" }, - { url = "https://files.pythonhosted.org/packages/c3/97/ac3f3feae7d522cf7ccec3d340bb0b2b61c56cb9767923df62a135092c6b/yarl-1.22.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:80ddf7a5f8c86cb3eb4bc9028b07bbbf1f08a96c5c0bc1244be5e8fefcb94147", size = 363893, upload-time = "2025-10-06T14:08:53.144Z" }, - { url = "https://files.pythonhosted.org/packages/06/49/f3219097403b9c84a4d079b1d7bda62dd9b86d0d6e4428c02d46ab2c77fc/yarl-1.22.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d332fc2e3c94dad927f2112395772a4e4fedbcf8f80efc21ed7cdfae4d574fdb", size = 371240, upload-time = "2025-10-06T14:08:55.036Z" }, - { url = "https://files.pythonhosted.org/packages/35/9f/06b765d45c0e44e8ecf0fe15c9eacbbde342bb5b7561c46944f107bfb6c3/yarl-1.22.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0cf71bf877efeac18b38d3930594c0948c82b64547c1cf420ba48722fe5509f6", size = 346965, upload-time = "2025-10-06T14:08:56.722Z" }, - { url = "https://files.pythonhosted.org/packages/c5/69/599e7cea8d0fcb1694323b0db0dda317fa3162f7b90166faddecf532166f/yarl-1.22.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:663e1cadaddae26be034a6ab6072449a8426ddb03d500f43daf952b74553bba0", size = 342026, upload-time = "2025-10-06T14:08:58.563Z" }, - { url = "https://files.pythonhosted.org/packages/95/6f/9dfd12c8bc90fea9eab39832ee32ea48f8e53d1256252a77b710c065c89f/yarl-1.22.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:6dcbb0829c671f305be48a7227918cfcd11276c2d637a8033a99a02b67bf9eda", size = 335637, upload-time = "2025-10-06T14:09:00.506Z" }, - { url = "https://files.pythonhosted.org/packages/57/2e/34c5b4eb9b07e16e873db5b182c71e5f06f9b5af388cdaa97736d79dd9a6/yarl-1.22.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f0d97c18dfd9a9af4490631905a3f131a8e4c9e80a39353919e2cfed8f00aedc", size = 359082, upload-time = "2025-10-06T14:09:01.936Z" }, - { url = "https://files.pythonhosted.org/packages/31/71/fa7e10fb772d273aa1f096ecb8ab8594117822f683bab7d2c5a89914c92a/yarl-1.22.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:437840083abe022c978470b942ff832c3940b2ad3734d424b7eaffcd07f76737", size = 357811, upload-time = "2025-10-06T14:09:03.445Z" }, - { url = "https://files.pythonhosted.org/packages/26/da/11374c04e8e1184a6a03cf9c8f5688d3e5cec83ed6f31ad3481b3207f709/yarl-1.22.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a899cbd98dce6f5d8de1aad31cb712ec0a530abc0a86bd6edaa47c1090138467", size = 351223, upload-time = "2025-10-06T14:09:05.401Z" }, - { url = "https://files.pythonhosted.org/packages/82/8f/e2d01f161b0c034a30410e375e191a5d27608c1f8693bab1a08b089ca096/yarl-1.22.0-cp310-cp310-win32.whl", hash = "sha256:595697f68bd1f0c1c159fcb97b661fc9c3f5db46498043555d04805430e79bea", size = 82118, upload-time = "2025-10-06T14:09:11.148Z" }, - { url = "https://files.pythonhosted.org/packages/62/46/94c76196642dbeae634c7a61ba3da88cd77bed875bf6e4a8bed037505aa6/yarl-1.22.0-cp310-cp310-win_amd64.whl", hash = "sha256:cb95a9b1adaa48e41815a55ae740cfda005758104049a640a398120bf02515ca", size = 86852, upload-time = "2025-10-06T14:09:12.958Z" }, - { url = "https://files.pythonhosted.org/packages/af/af/7df4f179d3b1a6dcb9a4bd2ffbc67642746fcafdb62580e66876ce83fff4/yarl-1.22.0-cp310-cp310-win_arm64.whl", hash = "sha256:b85b982afde6df99ecc996990d4ad7ccbdbb70e2a4ba4de0aecde5922ba98a0b", size = 82012, upload-time = "2025-10-06T14:09:14.664Z" }, - { url = "https://files.pythonhosted.org/packages/4d/27/5ab13fc84c76a0250afd3d26d5936349a35be56ce5785447d6c423b26d92/yarl-1.22.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ab72135b1f2db3fed3997d7e7dc1b80573c67138023852b6efb336a5eae6511", size = 141607, upload-time = "2025-10-06T14:09:16.298Z" }, - { url = "https://files.pythonhosted.org/packages/6a/a1/d065d51d02dc02ce81501d476b9ed2229d9a990818332242a882d5d60340/yarl-1.22.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:669930400e375570189492dc8d8341301578e8493aec04aebc20d4717f899dd6", size = 94027, upload-time = "2025-10-06T14:09:17.786Z" }, - { url = "https://files.pythonhosted.org/packages/c1/da/8da9f6a53f67b5106ffe902c6fa0164e10398d4e150d85838b82f424072a/yarl-1.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:792a2af6d58177ef7c19cbf0097aba92ca1b9cb3ffdd9c7470e156c8f9b5e028", size = 94963, upload-time = "2025-10-06T14:09:19.662Z" }, - { url = "https://files.pythonhosted.org/packages/68/fe/2c1f674960c376e29cb0bec1249b117d11738db92a6ccc4a530b972648db/yarl-1.22.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ea66b1c11c9150f1372f69afb6b8116f2dd7286f38e14ea71a44eee9ec51b9d", size = 368406, upload-time = "2025-10-06T14:09:21.402Z" }, - { url = "https://files.pythonhosted.org/packages/95/26/812a540e1c3c6418fec60e9bbd38e871eaba9545e94fa5eff8f4a8e28e1e/yarl-1.22.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3e2daa88dc91870215961e96a039ec73e4937da13cf77ce17f9cad0c18df3503", size = 336581, upload-time = "2025-10-06T14:09:22.98Z" }, - { url = "https://files.pythonhosted.org/packages/0b/f5/5777b19e26fdf98563985e481f8be3d8a39f8734147a6ebf459d0dab5a6b/yarl-1.22.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba440ae430c00eee41509353628600212112cd5018d5def7e9b05ea7ac34eb65", size = 388924, upload-time = "2025-10-06T14:09:24.655Z" }, - { url = "https://files.pythonhosted.org/packages/86/08/24bd2477bd59c0bbd994fe1d93b126e0472e4e3df5a96a277b0a55309e89/yarl-1.22.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e6438cc8f23a9c1478633d216b16104a586b9761db62bfacb6425bac0a36679e", size = 392890, upload-time = "2025-10-06T14:09:26.617Z" }, - { url = "https://files.pythonhosted.org/packages/46/00/71b90ed48e895667ecfb1eaab27c1523ee2fa217433ed77a73b13205ca4b/yarl-1.22.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c52a6e78aef5cf47a98ef8e934755abf53953379b7d53e68b15ff4420e6683d", size = 365819, upload-time = "2025-10-06T14:09:28.544Z" }, - { url = "https://files.pythonhosted.org/packages/30/2d/f715501cae832651d3282387c6a9236cd26bd00d0ff1e404b3dc52447884/yarl-1.22.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3b06bcadaac49c70f4c88af4ffcfbe3dc155aab3163e75777818092478bcbbe7", size = 363601, upload-time = "2025-10-06T14:09:30.568Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f9/a678c992d78e394e7126ee0b0e4e71bd2775e4334d00a9278c06a6cce96a/yarl-1.22.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6944b2dc72c4d7f7052683487e3677456050ff77fcf5e6204e98caf785ad1967", size = 358072, upload-time = "2025-10-06T14:09:32.528Z" }, - { url = "https://files.pythonhosted.org/packages/2c/d1/b49454411a60edb6fefdcad4f8e6dbba7d8019e3a508a1c5836cba6d0781/yarl-1.22.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d5372ca1df0f91a86b047d1277c2aaf1edb32d78bbcefffc81b40ffd18f027ed", size = 385311, upload-time = "2025-10-06T14:09:34.634Z" }, - { url = "https://files.pythonhosted.org/packages/87/e5/40d7a94debb8448c7771a916d1861d6609dddf7958dc381117e7ba36d9e8/yarl-1.22.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:51af598701f5299012b8416486b40fceef8c26fc87dc6d7d1f6fc30609ea0aa6", size = 381094, upload-time = "2025-10-06T14:09:36.268Z" }, - { url = "https://files.pythonhosted.org/packages/35/d8/611cc282502381ad855448643e1ad0538957fc82ae83dfe7762c14069e14/yarl-1.22.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b266bd01fedeffeeac01a79ae181719ff848a5a13ce10075adbefc8f1daee70e", size = 370944, upload-time = "2025-10-06T14:09:37.872Z" }, - { url = "https://files.pythonhosted.org/packages/2d/df/fadd00fb1c90e1a5a8bd731fa3d3de2e165e5a3666a095b04e31b04d9cb6/yarl-1.22.0-cp311-cp311-win32.whl", hash = "sha256:a9b1ba5610a4e20f655258d5a1fdc7ebe3d837bb0e45b581398b99eb98b1f5ca", size = 81804, upload-time = "2025-10-06T14:09:39.359Z" }, - { url = "https://files.pythonhosted.org/packages/b5/f7/149bb6f45f267cb5c074ac40c01c6b3ea6d8a620d34b337f6321928a1b4d/yarl-1.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:078278b9b0b11568937d9509b589ee83ef98ed6d561dfe2020e24a9fd08eaa2b", size = 86858, upload-time = "2025-10-06T14:09:41.068Z" }, - { url = "https://files.pythonhosted.org/packages/2b/13/88b78b93ad3f2f0b78e13bfaaa24d11cbc746e93fe76d8c06bf139615646/yarl-1.22.0-cp311-cp311-win_arm64.whl", hash = "sha256:b6a6f620cfe13ccec221fa312139135166e47ae169f8253f72a0abc0dae94376", size = 81637, upload-time = "2025-10-06T14:09:42.712Z" }, - { url = "https://files.pythonhosted.org/packages/75/ff/46736024fee3429b80a165a732e38e5d5a238721e634ab41b040d49f8738/yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f", size = 142000, upload-time = "2025-10-06T14:09:44.631Z" }, - { url = "https://files.pythonhosted.org/packages/5a/9a/b312ed670df903145598914770eb12de1bac44599549b3360acc96878df8/yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2", size = 94338, upload-time = "2025-10-06T14:09:46.372Z" }, - { url = "https://files.pythonhosted.org/packages/ba/f5/0601483296f09c3c65e303d60c070a5c19fcdbc72daa061e96170785bc7d/yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74", size = 94909, upload-time = "2025-10-06T14:09:48.648Z" }, - { url = "https://files.pythonhosted.org/packages/60/41/9a1fe0b73dbcefce72e46cf149b0e0a67612d60bfc90fb59c2b2efdfbd86/yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df", size = 372940, upload-time = "2025-10-06T14:09:50.089Z" }, - { url = "https://files.pythonhosted.org/packages/17/7a/795cb6dfee561961c30b800f0ed616b923a2ec6258b5def2a00bf8231334/yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb", size = 345825, upload-time = "2025-10-06T14:09:52.142Z" }, - { url = "https://files.pythonhosted.org/packages/d7/93/a58f4d596d2be2ae7bab1a5846c4d270b894958845753b2c606d666744d3/yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2", size = 386705, upload-time = "2025-10-06T14:09:54.128Z" }, - { url = "https://files.pythonhosted.org/packages/61/92/682279d0e099d0e14d7fd2e176bd04f48de1484f56546a3e1313cd6c8e7c/yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82", size = 396518, upload-time = "2025-10-06T14:09:55.762Z" }, - { url = "https://files.pythonhosted.org/packages/db/0f/0d52c98b8a885aeda831224b78f3be7ec2e1aa4a62091f9f9188c3c65b56/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a", size = 377267, upload-time = "2025-10-06T14:09:57.958Z" }, - { url = "https://files.pythonhosted.org/packages/22/42/d2685e35908cbeaa6532c1fc73e89e7f2efb5d8a7df3959ea8e37177c5a3/yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124", size = 365797, upload-time = "2025-10-06T14:09:59.527Z" }, - { url = "https://files.pythonhosted.org/packages/a2/83/cf8c7bcc6355631762f7d8bdab920ad09b82efa6b722999dfb05afa6cfac/yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa", size = 365535, upload-time = "2025-10-06T14:10:01.139Z" }, - { url = "https://files.pythonhosted.org/packages/25/e1/5302ff9b28f0c59cac913b91fe3f16c59a033887e57ce9ca5d41a3a94737/yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7", size = 382324, upload-time = "2025-10-06T14:10:02.756Z" }, - { url = "https://files.pythonhosted.org/packages/bf/cd/4617eb60f032f19ae3a688dc990d8f0d89ee0ea378b61cac81ede3e52fae/yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d", size = 383803, upload-time = "2025-10-06T14:10:04.552Z" }, - { url = "https://files.pythonhosted.org/packages/59/65/afc6e62bb506a319ea67b694551dab4a7e6fb7bf604e9bd9f3e11d575fec/yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520", size = 374220, upload-time = "2025-10-06T14:10:06.489Z" }, - { url = "https://files.pythonhosted.org/packages/e7/3d/68bf18d50dc674b942daec86a9ba922d3113d8399b0e52b9897530442da2/yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8", size = 81589, upload-time = "2025-10-06T14:10:09.254Z" }, - { url = "https://files.pythonhosted.org/packages/c8/9a/6ad1a9b37c2f72874f93e691b2e7ecb6137fb2b899983125db4204e47575/yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c", size = 87213, upload-time = "2025-10-06T14:10:11.369Z" }, - { url = "https://files.pythonhosted.org/packages/44/c5/c21b562d1680a77634d748e30c653c3ca918beb35555cff24986fff54598/yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74", size = 81330, upload-time = "2025-10-06T14:10:13.112Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980, upload-time = "2025-10-06T14:10:14.601Z" }, - { url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424, upload-time = "2025-10-06T14:10:16.115Z" }, - { url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821, upload-time = "2025-10-06T14:10:17.993Z" }, - { url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243, upload-time = "2025-10-06T14:10:19.44Z" }, - { url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361, upload-time = "2025-10-06T14:10:21.124Z" }, - { url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036, upload-time = "2025-10-06T14:10:22.902Z" }, - { url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671, upload-time = "2025-10-06T14:10:24.523Z" }, - { url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059, upload-time = "2025-10-06T14:10:26.406Z" }, - { url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356, upload-time = "2025-10-06T14:10:28.461Z" }, - { url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331, upload-time = "2025-10-06T14:10:30.541Z" }, - { url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload-time = "2025-10-06T14:10:33.352Z" }, - { url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload-time = "2025-10-06T14:10:35.034Z" }, - { url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload-time = "2025-10-06T14:10:37.76Z" }, - { url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload-time = "2025-10-06T14:10:39.649Z" }, - { url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload-time = "2025-10-06T14:10:41.313Z" }, - { url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload-time = "2025-10-06T14:10:43.167Z" }, - { url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload-time = "2025-10-06T14:10:44.643Z" }, - { url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload-time = "2025-10-06T14:10:46.554Z" }, - { url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload-time = "2025-10-06T14:10:48.007Z" }, - { url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967, upload-time = "2025-10-06T14:10:49.997Z" }, - { url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949, upload-time = "2025-10-06T14:10:52.004Z" }, - { url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818, upload-time = "2025-10-06T14:10:54.078Z" }, - { url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626, upload-time = "2025-10-06T14:10:55.767Z" }, - { url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129, upload-time = "2025-10-06T14:10:57.985Z" }, - { url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776, upload-time = "2025-10-06T14:10:59.633Z" }, - { url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879, upload-time = "2025-10-06T14:11:01.454Z" }, - { url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload-time = "2025-10-06T14:11:03.452Z" }, - { url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload-time = "2025-10-06T14:11:05.115Z" }, - { url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload-time = "2025-10-06T14:11:08.137Z" }, - { url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload-time = "2025-10-06T14:11:10.284Z" }, - { url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload-time = "2025-10-06T14:11:11.739Z" }, - { url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload-time = "2025-10-06T14:11:13.586Z" }, - { url = "https://files.pythonhosted.org/packages/46/b3/e20ef504049f1a1c54a814b4b9bed96d1ac0e0610c3b4da178f87209db05/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", size = 140520, upload-time = "2025-10-06T14:11:15.465Z" }, - { url = "https://files.pythonhosted.org/packages/e4/04/3532d990fdbab02e5ede063676b5c4260e7f3abea2151099c2aa745acc4c/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", size = 93504, upload-time = "2025-10-06T14:11:17.106Z" }, - { url = "https://files.pythonhosted.org/packages/11/63/ff458113c5c2dac9a9719ac68ee7c947cb621432bcf28c9972b1c0e83938/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", size = 94282, upload-time = "2025-10-06T14:11:19.064Z" }, - { url = "https://files.pythonhosted.org/packages/a7/bc/315a56aca762d44a6aaaf7ad253f04d996cb6b27bad34410f82d76ea8038/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", size = 372080, upload-time = "2025-10-06T14:11:20.996Z" }, - { url = "https://files.pythonhosted.org/packages/3f/3f/08e9b826ec2e099ea6e7c69a61272f4f6da62cb5b1b63590bb80ca2e4a40/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", size = 338696, upload-time = "2025-10-06T14:11:22.847Z" }, - { url = "https://files.pythonhosted.org/packages/e3/9f/90360108e3b32bd76789088e99538febfea24a102380ae73827f62073543/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", size = 387121, upload-time = "2025-10-06T14:11:24.889Z" }, - { url = "https://files.pythonhosted.org/packages/98/92/ab8d4657bd5b46a38094cfaea498f18bb70ce6b63508fd7e909bd1f93066/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", size = 394080, upload-time = "2025-10-06T14:11:27.307Z" }, - { url = "https://files.pythonhosted.org/packages/f5/e7/d8c5a7752fef68205296201f8ec2bf718f5c805a7a7e9880576c67600658/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", size = 372661, upload-time = "2025-10-06T14:11:29.387Z" }, - { url = "https://files.pythonhosted.org/packages/b6/2e/f4d26183c8db0bb82d491b072f3127fb8c381a6206a3a56332714b79b751/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", size = 364645, upload-time = "2025-10-06T14:11:31.423Z" }, - { url = "https://files.pythonhosted.org/packages/80/7c/428e5812e6b87cd00ee8e898328a62c95825bf37c7fa87f0b6bb2ad31304/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", size = 355361, upload-time = "2025-10-06T14:11:33.055Z" }, - { url = "https://files.pythonhosted.org/packages/ec/2a/249405fd26776f8b13c067378ef4d7dd49c9098d1b6457cdd152a99e96a9/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", size = 381451, upload-time = "2025-10-06T14:11:35.136Z" }, - { url = "https://files.pythonhosted.org/packages/67/a8/fb6b1adbe98cf1e2dd9fad71003d3a63a1bc22459c6e15f5714eb9323b93/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", size = 383814, upload-time = "2025-10-06T14:11:37.094Z" }, - { url = "https://files.pythonhosted.org/packages/d9/f9/3aa2c0e480fb73e872ae2814c43bc1e734740bb0d54e8cb2a95925f98131/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", size = 370799, upload-time = "2025-10-06T14:11:38.83Z" }, - { url = "https://files.pythonhosted.org/packages/50/3c/af9dba3b8b5eeb302f36f16f92791f3ea62e3f47763406abf6d5a4a3333b/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", size = 82990, upload-time = "2025-10-06T14:11:40.624Z" }, - { url = "https://files.pythonhosted.org/packages/ac/30/ac3a0c5bdc1d6efd1b41fa24d4897a4329b3b1e98de9449679dd327af4f0/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", size = 88292, upload-time = "2025-10-06T14:11:42.578Z" }, - { url = "https://files.pythonhosted.org/packages/df/0a/227ab4ff5b998a1b7410abc7b46c9b7a26b0ca9e86c34ba4b8d8bc7c63d5/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", size = 82888, upload-time = "2025-10-06T14:11:44.863Z" }, - { url = "https://files.pythonhosted.org/packages/06/5e/a15eb13db90abd87dfbefb9760c0f3f257ac42a5cac7e75dbc23bed97a9f/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", size = 146223, upload-time = "2025-10-06T14:11:46.796Z" }, - { url = "https://files.pythonhosted.org/packages/18/82/9665c61910d4d84f41a5bf6837597c89e665fa88aa4941080704645932a9/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", size = 95981, upload-time = "2025-10-06T14:11:48.845Z" }, - { url = "https://files.pythonhosted.org/packages/5d/9a/2f65743589809af4d0a6d3aa749343c4b5f4c380cc24a8e94a3c6625a808/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", size = 97303, upload-time = "2025-10-06T14:11:50.897Z" }, - { url = "https://files.pythonhosted.org/packages/b0/ab/5b13d3e157505c43c3b43b5a776cbf7b24a02bc4cccc40314771197e3508/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", size = 361820, upload-time = "2025-10-06T14:11:52.549Z" }, - { url = "https://files.pythonhosted.org/packages/fb/76/242a5ef4677615cf95330cfc1b4610e78184400699bdda0acb897ef5e49a/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", size = 323203, upload-time = "2025-10-06T14:11:54.225Z" }, - { url = "https://files.pythonhosted.org/packages/8c/96/475509110d3f0153b43d06164cf4195c64d16999e0c7e2d8a099adcd6907/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", size = 363173, upload-time = "2025-10-06T14:11:56.069Z" }, - { url = "https://files.pythonhosted.org/packages/c9/66/59db471aecfbd559a1fd48aedd954435558cd98c7d0da8b03cc6c140a32c/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", size = 373562, upload-time = "2025-10-06T14:11:58.783Z" }, - { url = "https://files.pythonhosted.org/packages/03/1f/c5d94abc91557384719da10ff166b916107c1b45e4d0423a88457071dd88/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", size = 339828, upload-time = "2025-10-06T14:12:00.686Z" }, - { url = "https://files.pythonhosted.org/packages/5f/97/aa6a143d3afba17b6465733681c70cf175af89f76ec8d9286e08437a7454/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", size = 347551, upload-time = "2025-10-06T14:12:02.628Z" }, - { url = "https://files.pythonhosted.org/packages/43/3c/45a2b6d80195959239a7b2a8810506d4eea5487dce61c2a3393e7fc3c52e/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", size = 334512, upload-time = "2025-10-06T14:12:04.871Z" }, - { url = "https://files.pythonhosted.org/packages/86/a0/c2ab48d74599c7c84cb104ebd799c5813de252bea0f360ffc29d270c2caa/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", size = 352400, upload-time = "2025-10-06T14:12:06.624Z" }, - { url = "https://files.pythonhosted.org/packages/32/75/f8919b2eafc929567d3d8411f72bdb1a2109c01caaab4ebfa5f8ffadc15b/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", size = 357140, upload-time = "2025-10-06T14:12:08.362Z" }, - { url = "https://files.pythonhosted.org/packages/cf/72/6a85bba382f22cf78add705d8c3731748397d986e197e53ecc7835e76de7/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", size = 341473, upload-time = "2025-10-06T14:12:10.994Z" }, - { url = "https://files.pythonhosted.org/packages/35/18/55e6011f7c044dc80b98893060773cefcfdbf60dfefb8cb2f58b9bacbd83/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", size = 89056, upload-time = "2025-10-06T14:12:13.317Z" }, - { url = "https://files.pythonhosted.org/packages/f9/86/0f0dccb6e59a9e7f122c5afd43568b1d31b8ab7dda5f1b01fb5c7025c9a9/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", size = 96292, upload-time = "2025-10-06T14:12:15.398Z" }, - { url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171, upload-time = "2025-10-06T14:12:16.935Z" }, - { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, + { url = "https://files.pythonhosted.org/packages/8b/0d/9cc638702f6fc3c7a3685bcc8cf2a9ed7d6206e932a49f5242658047ef51/yarl-1.23.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cff6d44cb13d39db2663a22b22305d10855efa0fa8015ddeacc40bc59b9d8107", size = 123764, upload-time = "2026-03-01T22:04:09.7Z" }, + { url = "https://files.pythonhosted.org/packages/7a/35/5a553687c5793df5429cd1db45909d4f3af7eee90014888c208d086a44f0/yarl-1.23.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e4c53f8347cd4200f0d70a48ad059cabaf24f5adc6ba08622a23423bc7efa10d", size = 86282, upload-time = "2026-03-01T22:04:11.892Z" }, + { url = "https://files.pythonhosted.org/packages/68/2e/c5a2234238f8ce37a8312b52801ee74117f576b1539eec8404a480434acc/yarl-1.23.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a6940a074fb3c48356ed0158a3ca5699c955ee4185b4d7d619be3c327143e05", size = 86053, upload-time = "2026-03-01T22:04:13.292Z" }, + { url = "https://files.pythonhosted.org/packages/74/3f/bbd8ff36fb038622797ffbaf7db314918bb4d76f1cc8a4f9ca7a55fe5195/yarl-1.23.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ed5f69ce7be7902e5c70ea19eb72d20abf7d725ab5d49777d696e32d4fc1811d", size = 99395, upload-time = "2026-03-01T22:04:15.133Z" }, + { url = "https://files.pythonhosted.org/packages/77/04/9516bc4e269d2a3ec9c6779fcdeac51ce5b3a9b0156f06ac7152e5bba864/yarl-1.23.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:389871e65468400d6283c0308e791a640b5ab5c83bcee02a2f51295f95e09748", size = 92143, upload-time = "2026-03-01T22:04:16.829Z" }, + { url = "https://files.pythonhosted.org/packages/c7/63/88802d1f6b1cb1fc67d67a58cd0cf8a1790de4ce7946e434240f1d60ab4a/yarl-1.23.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dda608c88cf709b1d406bdfcd84d8d63cff7c9e577a403c6108ce8ce9dcc8764", size = 107643, upload-time = "2026-03-01T22:04:18.519Z" }, + { url = "https://files.pythonhosted.org/packages/8e/db/4f9b838f4d8bdd6f0f385aed8bbf21c71ed11a0b9983305c302cbd557815/yarl-1.23.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c4fe09e0780c6c3bf2b7d4af02ee2394439d11a523bbcf095cf4747c2932007", size = 108700, upload-time = "2026-03-01T22:04:20.373Z" }, + { url = "https://files.pythonhosted.org/packages/50/12/95a1d33f04a79c402664070d43b8b9f72dc18914e135b345b611b0b1f8cc/yarl-1.23.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31c9921eb8bd12633b41ad27686bbb0b1a2a9b8452bfdf221e34f311e9942ed4", size = 102769, upload-time = "2026-03-01T22:04:23.055Z" }, + { url = "https://files.pythonhosted.org/packages/86/65/91a0285f51321369fd1a8308aa19207520c5f0587772cfc2e03fc2467e90/yarl-1.23.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5f10fd85e4b75967468af655228fbfd212bdf66db1c0d135065ce288982eda26", size = 101114, upload-time = "2026-03-01T22:04:25.031Z" }, + { url = "https://files.pythonhosted.org/packages/58/80/c7c8244fc3e5bc483dc71a09560f43b619fab29301a0f0a8f936e42865c7/yarl-1.23.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dbf507e9ef5688bada447a24d68b4b58dd389ba93b7afc065a2ba892bea54769", size = 98883, upload-time = "2026-03-01T22:04:27.281Z" }, + { url = "https://files.pythonhosted.org/packages/86/e7/71ca9cc9ca79c0b7d491216177d1aed559d632947b8ffb0ee60f7d8b23e3/yarl-1.23.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:85e9beda1f591bc73e77ea1c51965c68e98dafd0fec72cdd745f77d727466716", size = 94172, upload-time = "2026-03-01T22:04:28.554Z" }, + { url = "https://files.pythonhosted.org/packages/6a/3f/6c6c8a0fe29c26fb2db2e8d32195bb84ec1bfb8f1d32e7f73b787fcf349b/yarl-1.23.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:0e1fdaa14ef51366d7757b45bde294e95f6c8c049194e793eedb8387c86d5993", size = 107010, upload-time = "2026-03-01T22:04:30.385Z" }, + { url = "https://files.pythonhosted.org/packages/56/38/12730c05e5ad40a76374d440ed8b0899729a96c250516d91c620a6e38fc2/yarl-1.23.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:75e3026ab649bf48f9a10c0134512638725b521340293f202a69b567518d94e0", size = 100285, upload-time = "2026-03-01T22:04:31.752Z" }, + { url = "https://files.pythonhosted.org/packages/34/92/6a7be9239f2347234e027284e7a5f74b1140cc86575e7b469d13fba1ebfe/yarl-1.23.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:80e6d33a3d42a7549b409f199857b4fb54e2103fc44fb87605b6663b7a7ff750", size = 108230, upload-time = "2026-03-01T22:04:33.844Z" }, + { url = "https://files.pythonhosted.org/packages/5e/81/4aebccfa9376bd98b9d8bfad20621a57d3e8cfc5b8631c1fa5f62cdd03f4/yarl-1.23.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5ec2f42d41ccbd5df0270d7df31618a8ee267bfa50997f5d720ddba86c4a83a6", size = 103008, upload-time = "2026-03-01T22:04:35.856Z" }, + { url = "https://files.pythonhosted.org/packages/38/0f/0b4e3edcec794a86b853b0c6396c0a888d72dfce19b2d88c02ac289fb6c1/yarl-1.23.0-cp310-cp310-win32.whl", hash = "sha256:debe9c4f41c32990771be5c22b56f810659f9ddf3d63f67abfdcaa2c6c9c5c1d", size = 83073, upload-time = "2026-03-01T22:04:38.268Z" }, + { url = "https://files.pythonhosted.org/packages/a0/71/ad95c33da18897e4c636528bbc24a1dd23fe16797de8bc4ec667b8db0ba4/yarl-1.23.0-cp310-cp310-win_amd64.whl", hash = "sha256:ab5f043cb8a2d71c981c09c510da013bc79fd661f5c60139f00dd3c3cc4f2ffb", size = 87328, upload-time = "2026-03-01T22:04:39.558Z" }, + { url = "https://files.pythonhosted.org/packages/e2/14/dfa369523c79bccf9c9c746b0a63eb31f65db9418ac01275f7950962e504/yarl-1.23.0-cp310-cp310-win_arm64.whl", hash = "sha256:263cd4f47159c09b8b685890af949195b51d1aa82ba451c5847ca9bc6413c220", size = 82463, upload-time = "2026-03-01T22:04:41.454Z" }, + { url = "https://files.pythonhosted.org/packages/a2/aa/60da938b8f0997ba3a911263c40d82b6f645a67902a490b46f3355e10fae/yarl-1.23.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b35d13d549077713e4414f927cdc388d62e543987c572baee613bf82f11a4b99", size = 123641, upload-time = "2026-03-01T22:04:42.841Z" }, + { url = "https://files.pythonhosted.org/packages/24/84/e237607faf4e099dbb8a4f511cfd5efcb5f75918baad200ff7380635631b/yarl-1.23.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cbb0fef01f0c6b38cb0f39b1f78fc90b807e0e3c86a7ff3ce74ad77ce5c7880c", size = 86248, upload-time = "2026-03-01T22:04:44.757Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0d/71ceabc14c146ba8ee3804ca7b3d42b1664c8440439de5214d366fec7d3a/yarl-1.23.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc52310451fc7c629e13c4e061cbe2dd01684d91f2f8ee2821b083c58bd72432", size = 85988, upload-time = "2026-03-01T22:04:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/8c/6c/4a90d59c572e46b270ca132aca66954f1175abd691f74c1ef4c6711828e2/yarl-1.23.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2c6b50c7b0464165472b56b42d4c76a7b864597007d9c085e8b63e185cf4a7a", size = 100566, upload-time = "2026-03-01T22:04:47.639Z" }, + { url = "https://files.pythonhosted.org/packages/49/fb/c438fb5108047e629f6282a371e6e91cf3f97ee087c4fb748a1f32ceef55/yarl-1.23.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aafe5dcfda86c8af00386d7781d4c2181b5011b7be3f2add5e99899ea925df05", size = 92079, upload-time = "2026-03-01T22:04:48.925Z" }, + { url = "https://files.pythonhosted.org/packages/d9/13/d269aa1aed3e4f50a5a103f96327210cc5fa5dd2d50882778f13c7a14606/yarl-1.23.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9ee33b875f0b390564c1fb7bc528abf18c8ee6073b201c6ae8524aca778e2d83", size = 108741, upload-time = "2026-03-01T22:04:50.838Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/115b16f22c37ea4437d323e472945bea97301c8ec6089868fa560abab590/yarl-1.23.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4c41e021bc6d7affb3364dc1e1e5fa9582b470f283748784bd6ea0558f87f42c", size = 108099, upload-time = "2026-03-01T22:04:52.499Z" }, + { url = "https://files.pythonhosted.org/packages/9a/64/c53487d9f4968045b8afa51aed7ca44f58b2589e772f32745f3744476c82/yarl-1.23.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99c8a9ed30f4164bc4c14b37a90208836cbf50d4ce2a57c71d0f52c7fb4f7598", size = 102678, upload-time = "2026-03-01T22:04:55.176Z" }, + { url = "https://files.pythonhosted.org/packages/85/59/cd98e556fbb2bf8fab29c1a722f67ad45c5f3447cac798ab85620d1e70af/yarl-1.23.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2af5c81a1f124609d5f33507082fc3f739959d4719b56877ab1ee7e7b3d602b", size = 100803, upload-time = "2026-03-01T22:04:56.588Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c0/b39770b56d4a9f0bb5f77e2f1763cd2d75cc2f6c0131e3b4c360348fcd65/yarl-1.23.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6b41389c19b07c760c7e427a3462e8ab83c4bb087d127f0e854c706ce1b9215c", size = 100163, upload-time = "2026-03-01T22:04:58.492Z" }, + { url = "https://files.pythonhosted.org/packages/e7/64/6980f99ab00e1f0ff67cb84766c93d595b067eed07439cfccfc8fb28c1a6/yarl-1.23.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:1dc702e42d0684f42d6519c8d581e49c96cefaaab16691f03566d30658ee8788", size = 93859, upload-time = "2026-03-01T22:05:00.268Z" }, + { url = "https://files.pythonhosted.org/packages/38/69/912e6c5e146793e5d4b5fe39ff5b00f4d22463dfd5a162bec565ac757673/yarl-1.23.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0e40111274f340d32ebcc0a5668d54d2b552a6cca84c9475859d364b380e3222", size = 108202, upload-time = "2026-03-01T22:05:02.273Z" }, + { url = "https://files.pythonhosted.org/packages/59/97/35ca6767524687ad64e5f5c31ad54bc76d585585a9fcb40f649e7e82ffed/yarl-1.23.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4764a6a7588561a9aef92f65bda2c4fb58fe7c675c0883862e6df97559de0bfb", size = 99866, upload-time = "2026-03-01T22:05:03.597Z" }, + { url = "https://files.pythonhosted.org/packages/d3/1c/1a3387ee6d73589f6f2a220ae06f2984f6c20b40c734989b0a44f5987308/yarl-1.23.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:03214408cfa590df47728b84c679ae4ef00be2428e11630277be0727eba2d7cc", size = 107852, upload-time = "2026-03-01T22:05:04.986Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b8/35c0750fcd5a3f781058bfd954515dd4b1eab45e218cbb85cf11132215f1/yarl-1.23.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:170e26584b060879e29fac213e4228ef063f39128723807a312e5c7fec28eff2", size = 102919, upload-time = "2026-03-01T22:05:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1c/9a1979aec4a81896d597bcb2177827f2dbee3f5b7cc48b2d0dadb644b41d/yarl-1.23.0-cp311-cp311-win32.whl", hash = "sha256:51430653db848d258336cfa0244427b17d12db63d42603a55f0d4546f50f25b5", size = 82602, upload-time = "2026-03-01T22:05:08.444Z" }, + { url = "https://files.pythonhosted.org/packages/93/22/b85eca6fa2ad9491af48c973e4c8cf6b103a73dbb271fe3346949449fca0/yarl-1.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:bf49a3ae946a87083ef3a34c8f677ae4243f5b824bfc4c69672e72b3d6719d46", size = 87461, upload-time = "2026-03-01T22:05:10.145Z" }, + { url = "https://files.pythonhosted.org/packages/93/95/07e3553fe6f113e6864a20bdc53a78113cda3b9ced8784ee52a52c9f80d8/yarl-1.23.0-cp311-cp311-win_arm64.whl", hash = "sha256:b39cb32a6582750b6cc77bfb3c49c0f8760dc18dc96ec9fb55fbb0f04e08b928", size = 82336, upload-time = "2026-03-01T22:05:11.554Z" }, + { url = "https://files.pythonhosted.org/packages/88/8a/94615bc31022f711add374097ad4144d569e95ff3c38d39215d07ac153a0/yarl-1.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1932b6b8bba8d0160a9d1078aae5838a66039e8832d41d2992daa9a3a08f7860", size = 124737, upload-time = "2026-03-01T22:05:12.897Z" }, + { url = "https://files.pythonhosted.org/packages/e3/6f/c6554045d59d64052698add01226bc867b52fe4a12373415d7991fdca95d/yarl-1.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069", size = 87029, upload-time = "2026-03-01T22:05:14.376Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/725ecc166d53438bc88f76822ed4b1e3b10756e790bafd7b523fe97c322d/yarl-1.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25", size = 86310, upload-time = "2026-03-01T22:05:15.71Z" }, + { url = "https://files.pythonhosted.org/packages/99/30/58260ed98e6ff7f90ba84442c1ddd758c9170d70327394a6227b310cd60f/yarl-1.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8", size = 97587, upload-time = "2026-03-01T22:05:17.384Z" }, + { url = "https://files.pythonhosted.org/packages/76/0a/8b08aac08b50682e65759f7f8dde98ae8168f72487e7357a5d684c581ef9/yarl-1.23.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072", size = 92528, upload-time = "2026-03-01T22:05:18.804Z" }, + { url = "https://files.pythonhosted.org/packages/52/07/0b7179101fe5f8385ec6c6bb5d0cb9f76bd9fb4a769591ab6fb5cdbfc69a/yarl-1.23.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8", size = 105339, upload-time = "2026-03-01T22:05:20.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8a/36d82869ab5ec829ca8574dfcb92b51286fcfb1e9c7a73659616362dc880/yarl-1.23.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7", size = 105061, upload-time = "2026-03-01T22:05:22.268Z" }, + { url = "https://files.pythonhosted.org/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51", size = 100132, upload-time = "2026-03-01T22:05:23.638Z" }, + { url = "https://files.pythonhosted.org/packages/cf/26/9c89acf82f08a52cb52d6d39454f8d18af15f9d386a23795389d1d423823/yarl-1.23.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67", size = 99289, upload-time = "2026-03-01T22:05:25.749Z" }, + { url = "https://files.pythonhosted.org/packages/6f/54/5b0db00d2cb056922356104468019c0a132e89c8d3ab67d8ede9f4483d2a/yarl-1.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7", size = 96950, upload-time = "2026-03-01T22:05:27.318Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/10fa93811fd439341fad7e0718a86aca0de9548023bbb403668d6555acab/yarl-1.23.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d", size = 93960, upload-time = "2026-03-01T22:05:28.738Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d2/8ae2e6cd77d0805f4526e30ec43b6f9a3dfc542d401ac4990d178e4bf0cf/yarl-1.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760", size = 104703, upload-time = "2026-03-01T22:05:30.438Z" }, + { url = "https://files.pythonhosted.org/packages/2f/0c/b3ceacf82c3fe21183ce35fa2acf5320af003d52bc1fcf5915077681142e/yarl-1.23.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2", size = 98325, upload-time = "2026-03-01T22:05:31.835Z" }, + { url = "https://files.pythonhosted.org/packages/9d/e0/12900edd28bdab91a69bd2554b85ad7b151f64e8b521fe16f9ad2f56477a/yarl-1.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86", size = 105067, upload-time = "2026-03-01T22:05:33.358Z" }, + { url = "https://files.pythonhosted.org/packages/15/61/74bb1182cf79c9bbe4eb6b1f14a57a22d7a0be5e9cedf8e2d5c2086474c3/yarl-1.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34", size = 100285, upload-time = "2026-03-01T22:05:35.4Z" }, + { url = "https://files.pythonhosted.org/packages/69/7f/cd5ef733f2550de6241bd8bd8c3febc78158b9d75f197d9c7baa113436af/yarl-1.23.0-cp312-cp312-win32.whl", hash = "sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d", size = 82359, upload-time = "2026-03-01T22:05:36.811Z" }, + { url = "https://files.pythonhosted.org/packages/f5/be/25216a49daeeb7af2bec0db22d5e7df08ed1d7c9f65d78b14f3b74fd72fc/yarl-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e", size = 87674, upload-time = "2026-03-01T22:05:38.171Z" }, + { url = "https://files.pythonhosted.org/packages/d2/35/aeab955d6c425b227d5b7247eafb24f2653fedc32f95373a001af5dfeb9e/yarl-1.23.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9", size = 81879, upload-time = "2026-03-01T22:05:40.006Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4b/a0a6e5d0ee8a2f3a373ddef8a4097d74ac901ac363eea1440464ccbe0898/yarl-1.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e", size = 123796, upload-time = "2026-03-01T22:05:41.412Z" }, + { url = "https://files.pythonhosted.org/packages/67/b6/8925d68af039b835ae876db5838e82e76ec87b9782ecc97e192b809c4831/yarl-1.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5", size = 86547, upload-time = "2026-03-01T22:05:42.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/50/06d511cc4b8e0360d3c94af051a768e84b755c5eb031b12adaaab6dec6e5/yarl-1.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b", size = 85854, upload-time = "2026-03-01T22:05:44.85Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f4/4e30b250927ffdab4db70da08b9b8d2194d7c7b400167b8fbeca1e4701ca/yarl-1.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035", size = 98351, upload-time = "2026-03-01T22:05:46.836Z" }, + { url = "https://files.pythonhosted.org/packages/86/fc/4118c5671ea948208bdb1492d8b76bdf1453d3e73df051f939f563e7dcc5/yarl-1.23.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9d9a4d06d3481eab79803beb4d9bd6f6a8e781ec078ac70d7ef2dcc29d1bea5", size = 92711, upload-time = "2026-03-01T22:05:48.316Z" }, + { url = "https://files.pythonhosted.org/packages/56/11/1ed91d42bd9e73c13dc9e7eb0dd92298d75e7ac4dd7f046ad0c472e231cd/yarl-1.23.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f514f6474e04179d3d33175ed3f3e31434d3130d42ec153540d5b157deefd735", size = 106014, upload-time = "2026-03-01T22:05:50.028Z" }, + { url = "https://files.pythonhosted.org/packages/ce/c9/74e44e056a23fbc33aca71779ef450ca648a5bc472bdad7a82339918f818/yarl-1.23.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fda207c815b253e34f7e1909840fd14299567b1c0eb4908f8c2ce01a41265401", size = 105557, upload-time = "2026-03-01T22:05:51.416Z" }, + { url = "https://files.pythonhosted.org/packages/66/fe/b1e10b08d287f518994f1e2ff9b6d26f0adeecd8dd7d533b01bab29a3eda/yarl-1.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4", size = 101559, upload-time = "2026-03-01T22:05:52.872Z" }, + { url = "https://files.pythonhosted.org/packages/72/59/c5b8d94b14e3d3c2a9c20cb100119fd534ab5a14b93673ab4cc4a4141ea5/yarl-1.23.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7504f2b476d21653e4d143f44a175f7f751cd41233525312696c76aa3dbb23f", size = 100502, upload-time = "2026-03-01T22:05:54.954Z" }, + { url = "https://files.pythonhosted.org/packages/77/4f/96976cb54cbfc5c9fd73ed4c51804f92f209481d1fb190981c0f8a07a1d7/yarl-1.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:578110dd426f0d209d1509244e6d4a3f1a3e9077655d98c5f22583d63252a08a", size = 98027, upload-time = "2026-03-01T22:05:56.409Z" }, + { url = "https://files.pythonhosted.org/packages/63/6e/904c4f476471afdbad6b7e5b70362fb5810e35cd7466529a97322b6f5556/yarl-1.23.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:609d3614d78d74ebe35f54953c5bbd2ac647a7ddb9c30a5d877580f5e86b22f2", size = 95369, upload-time = "2026-03-01T22:05:58.141Z" }, + { url = "https://files.pythonhosted.org/packages/9d/40/acfcdb3b5f9d68ef499e39e04d25e141fe90661f9d54114556cf83be8353/yarl-1.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4966242ec68afc74c122f8459abd597afd7d8a60dc93d695c1334c5fd25f762f", size = 105565, upload-time = "2026-03-01T22:06:00.286Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c6/31e28f3a6ba2869c43d124f37ea5260cac9c9281df803c354b31f4dd1f3c/yarl-1.23.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e0fd068364a6759bc794459f0a735ab151d11304346332489c7972bacbe9e72b", size = 99813, upload-time = "2026-03-01T22:06:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/08/1f/6f65f59e72d54aa467119b63fc0b0b1762eff0232db1f4720cd89e2f4a17/yarl-1.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:39004f0ad156da43e86aa71f44e033de68a44e5a31fc53507b36dd253970054a", size = 105632, upload-time = "2026-03-01T22:06:03.188Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c4/18b178a69935f9e7a338127d5b77d868fdc0f0e49becd286d51b3a18c61d/yarl-1.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e5723c01a56c5028c807c701aa66722916d2747ad737a046853f6c46f4875543", size = 101895, upload-time = "2026-03-01T22:06:04.651Z" }, + { url = "https://files.pythonhosted.org/packages/8f/54/f5b870b5505663911dba950a8e4776a0dbd51c9c54c0ae88e823e4b874a0/yarl-1.23.0-cp313-cp313-win32.whl", hash = "sha256:1b6b572edd95b4fa8df75de10b04bc81acc87c1c7d16bcdd2035b09d30acc957", size = 82356, upload-time = "2026-03-01T22:06:06.04Z" }, + { url = "https://files.pythonhosted.org/packages/7a/84/266e8da36879c6edcd37b02b547e2d9ecdfea776be49598e75696e3316e1/yarl-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:baaf55442359053c7d62f6f8413a62adba3205119bcb6f49594894d8be47e5e3", size = 87515, upload-time = "2026-03-01T22:06:08.107Z" }, + { url = "https://files.pythonhosted.org/packages/00/fd/7e1c66efad35e1649114fa13f17485f62881ad58edeeb7f49f8c5e748bf9/yarl-1.23.0-cp313-cp313-win_arm64.whl", hash = "sha256:fb4948814a2a98e3912505f09c9e7493b1506226afb1f881825368d6fb776ee3", size = 81785, upload-time = "2026-03-01T22:06:10.181Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fc/119dd07004f17ea43bb91e3ece6587759edd7519d6b086d16bfbd3319982/yarl-1.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:aecfed0b41aa72b7881712c65cf764e39ce2ec352324f5e0837c7048d9e6daaa", size = 130719, upload-time = "2026-03-01T22:06:11.708Z" }, + { url = "https://files.pythonhosted.org/packages/e6/0d/9f2348502fbb3af409e8f47730282cd6bc80dec6630c1e06374d882d6eb2/yarl-1.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a41bcf68efd19073376eb8cf948b8d9be0af26256403e512bb18f3966f1f9120", size = 89690, upload-time = "2026-03-01T22:06:13.429Z" }, + { url = "https://files.pythonhosted.org/packages/50/93/e88f3c80971b42cfc83f50a51b9d165a1dbf154b97005f2994a79f212a07/yarl-1.23.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cde9a2ecd91668bcb7f077c4966d8ceddb60af01b52e6e3e2680e4cf00ad1a59", size = 89851, upload-time = "2026-03-01T22:06:15.53Z" }, + { url = "https://files.pythonhosted.org/packages/1c/07/61c9dd8ba8f86473263b4036f70fb594c09e99c0d9737a799dfd8bc85651/yarl-1.23.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5023346c4ee7992febc0068e7593de5fa2bf611848c08404b35ebbb76b1b0512", size = 95874, upload-time = "2026-03-01T22:06:17.553Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e9/f9ff8ceefba599eac6abddcfb0b3bee9b9e636e96dbf54342a8577252379/yarl-1.23.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1009abedb49ae95b136a8904a3f71b342f849ffeced2d3747bf29caeda218c4", size = 88710, upload-time = "2026-03-01T22:06:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/eb/78/0231bfcc5d4c8eec220bc2f9ef82cb4566192ea867a7c5b4148f44f6cbcd/yarl-1.23.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a8d00f29b42f534cc8aa3931cfe773b13b23e561e10d2b26f27a8d309b0e82a1", size = 101033, upload-time = "2026-03-01T22:06:21.203Z" }, + { url = "https://files.pythonhosted.org/packages/cd/9b/30ea5239a61786f18fd25797151a17fbb3be176977187a48d541b5447dd4/yarl-1.23.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:95451e6ce06c3e104556d73b559f5da6c34a069b6b62946d3ad66afcd51642ea", size = 100817, upload-time = "2026-03-01T22:06:22.738Z" }, + { url = "https://files.pythonhosted.org/packages/62/e2/a4980481071791bc83bce2b7a1a1f7adcabfa366007518b4b845e92eeee3/yarl-1.23.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531ef597132086b6cf96faa7c6c1dcd0361dd5f1694e5cc30375907b9b7d3ea9", size = 97482, upload-time = "2026-03-01T22:06:24.21Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1e/304a00cf5f6100414c4b5a01fc7ff9ee724b62158a08df2f8170dfc72a2d/yarl-1.23.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88f9fb0116fbfcefcab70f85cf4b74a2b6ce5d199c41345296f49d974ddb4123", size = 95949, upload-time = "2026-03-01T22:06:25.697Z" }, + { url = "https://files.pythonhosted.org/packages/68/03/093f4055ed4cae649ac53bca3d180bd37102e9e11d048588e9ab0c0108d0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e7b0460976dc75cb87ad9cc1f9899a4b97751e7d4e77ab840fc9b6d377b8fd24", size = 95839, upload-time = "2026-03-01T22:06:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/b9/28/4c75ebb108f322aa8f917ae10a8ffa4f07cae10a8a627b64e578617df6a0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:115136c4a426f9da976187d238e84139ff6b51a20839aa6e3720cd1026d768de", size = 90696, upload-time = "2026-03-01T22:06:29.048Z" }, + { url = "https://files.pythonhosted.org/packages/23/9c/42c2e2dd91c1a570402f51bdf066bfdb1241c2240ba001967bad778e77b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ead11956716a940c1abc816b7df3fa2b84d06eaed8832ca32f5c5e058c65506b", size = 100865, upload-time = "2026-03-01T22:06:30.525Z" }, + { url = "https://files.pythonhosted.org/packages/74/05/1bcd60a8a0a914d462c305137246b6f9d167628d73568505fce3f1cb2e65/yarl-1.23.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:fe8f8f5e70e6dbdfca9882cd9deaac058729bcf323cf7a58660901e55c9c94f6", size = 96234, upload-time = "2026-03-01T22:06:32.692Z" }, + { url = "https://files.pythonhosted.org/packages/90/b2/f52381aac396d6778ce516b7bc149c79e65bfc068b5de2857ab69eeea3b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:a0e317df055958a0c1e79e5d2aa5a5eaa4a6d05a20d4b0c9c3f48918139c9fc6", size = 100295, upload-time = "2026-03-01T22:06:34.268Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/638bae5bbf1113a659b2435d8895474598afe38b4a837103764f603aba56/yarl-1.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f0fd84de0c957b2d280143522c4f91a73aada1923caee763e24a2b3fda9f8a5", size = 97784, upload-time = "2026-03-01T22:06:35.864Z" }, + { url = "https://files.pythonhosted.org/packages/80/25/a3892b46182c586c202629fc2159aa13975d3741d52ebd7347fd501d48d5/yarl-1.23.0-cp313-cp313t-win32.whl", hash = "sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595", size = 88313, upload-time = "2026-03-01T22:06:37.39Z" }, + { url = "https://files.pythonhosted.org/packages/43/68/8c5b36aa5178900b37387937bc2c2fe0e9505537f713495472dcf6f6fccc/yarl-1.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090", size = 94932, upload-time = "2026-03-01T22:06:39.579Z" }, + { url = "https://files.pythonhosted.org/packages/c6/cc/d79ba8292f51f81f4dc533a8ccfb9fc6992cabf0998ed3245de7589dc07c/yarl-1.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144", size = 84786, upload-time = "2026-03-01T22:06:41.988Z" }, + { url = "https://files.pythonhosted.org/packages/90/98/b85a038d65d1b92c3903ab89444f48d3cee490a883477b716d7a24b1a78c/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", size = 124455, upload-time = "2026-03-01T22:06:43.615Z" }, + { url = "https://files.pythonhosted.org/packages/39/54/bc2b45559f86543d163b6e294417a107bb87557609007c007ad889afec18/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", size = 86752, upload-time = "2026-03-01T22:06:45.425Z" }, + { url = "https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", size = 86291, upload-time = "2026-03-01T22:06:46.974Z" }, + { url = "https://files.pythonhosted.org/packages/ea/d8/d1cb2378c81dd729e98c716582b1ccb08357e8488e4c24714658cc6630e8/yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319", size = 99026, upload-time = "2026-03-01T22:06:48.459Z" }, + { url = "https://files.pythonhosted.org/packages/0a/ff/7196790538f31debe3341283b5b0707e7feb947620fc5e8236ef28d44f72/yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434", size = 92355, upload-time = "2026-03-01T22:06:50.306Z" }, + { url = "https://files.pythonhosted.org/packages/c1/56/25d58c3eddde825890a5fe6aa1866228377354a3c39262235234ab5f616b/yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723", size = 106417, upload-time = "2026-03-01T22:06:52.1Z" }, + { url = "https://files.pythonhosted.org/packages/51/8a/882c0e7bc8277eb895b31bce0138f51a1ba551fc2e1ec6753ffc1e7c1377/yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039", size = 106422, upload-time = "2026-03-01T22:06:54.424Z" }, + { url = "https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52", size = 101915, upload-time = "2026-03-01T22:06:55.895Z" }, + { url = "https://files.pythonhosted.org/packages/18/6a/530e16aebce27c5937920f3431c628a29a4b6b430fab3fd1c117b26ff3f6/yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c", size = 100690, upload-time = "2026-03-01T22:06:58.21Z" }, + { url = "https://files.pythonhosted.org/packages/88/08/93749219179a45e27b036e03260fda05190b911de8e18225c294ac95bbc9/yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae", size = 98750, upload-time = "2026-03-01T22:06:59.794Z" }, + { url = "https://files.pythonhosted.org/packages/d9/cf/ea424a004969f5d81a362110a6ac1496d79efdc6d50c2c4b2e3ea0fc2519/yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e", size = 94685, upload-time = "2026-03-01T22:07:01.375Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b7/14341481fe568e2b0408bcf1484c652accafe06a0ade9387b5d3fd9df446/yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85", size = 106009, upload-time = "2026-03-01T22:07:03.151Z" }, + { url = "https://files.pythonhosted.org/packages/0a/e6/5c744a9b54f4e8007ad35bce96fbc9218338e84812d36f3390cea616881a/yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd", size = 100033, upload-time = "2026-03-01T22:07:04.701Z" }, + { url = "https://files.pythonhosted.org/packages/0c/23/e3bfc188d0b400f025bc49d99793d02c9abe15752138dcc27e4eaf0c4a9e/yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6", size = 106483, upload-time = "2026-03-01T22:07:06.231Z" }, + { url = "https://files.pythonhosted.org/packages/72/42/f0505f949a90b3f8b7a363d6cbdf398f6e6c58946d85c6d3a3bc70595b26/yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe", size = 102175, upload-time = "2026-03-01T22:07:08.4Z" }, + { url = "https://files.pythonhosted.org/packages/aa/65/b39290f1d892a9dd671d1c722014ca062a9c35d60885d57e5375db0404b5/yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169", size = 83871, upload-time = "2026-03-01T22:07:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70", size = 89093, upload-time = "2026-03-01T22:07:11.501Z" }, + { url = "https://files.pythonhosted.org/packages/e0/7d/8a84dc9381fd4412d5e7ff04926f9865f6372b4c2fd91e10092e65d29eb8/yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e", size = 83384, upload-time = "2026-03-01T22:07:13.069Z" }, + { url = "https://files.pythonhosted.org/packages/dd/8d/d2fad34b1c08aa161b74394183daa7d800141aaaee207317e82c790b418d/yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679", size = 131019, upload-time = "2026-03-01T22:07:14.903Z" }, + { url = "https://files.pythonhosted.org/packages/19/ff/33009a39d3ccf4b94d7d7880dfe17fb5816c5a4fe0096d9b56abceea9ac7/yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412", size = 89894, upload-time = "2026-03-01T22:07:17.372Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f1/dab7ac5e7306fb79c0190766a3c00b4cb8d09a1f390ded68c85a5934faf5/yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4", size = 89979, upload-time = "2026-03-01T22:07:19.361Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b1/08e95f3caee1fad6e65017b9f26c1d79877b502622d60e517de01e72f95d/yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c", size = 95943, upload-time = "2026-03-01T22:07:21.266Z" }, + { url = "https://files.pythonhosted.org/packages/c0/cc/6409f9018864a6aa186c61175b977131f373f1988e198e031236916e87e4/yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4", size = 88786, upload-time = "2026-03-01T22:07:23.129Z" }, + { url = "https://files.pythonhosted.org/packages/76/40/cc22d1d7714b717fde2006fad2ced5efe5580606cb059ae42117542122f3/yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94", size = 101307, upload-time = "2026-03-01T22:07:24.689Z" }, + { url = "https://files.pythonhosted.org/packages/8f/0d/476c38e85ddb4c6ec6b20b815bdd779aa386a013f3d8b85516feee55c8dc/yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28", size = 100904, upload-time = "2026-03-01T22:07:26.287Z" }, + { url = "https://files.pythonhosted.org/packages/72/32/0abe4a76d59adf2081dcb0397168553ece4616ada1c54d1c49d8936c74f8/yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6", size = 97728, upload-time = "2026-03-01T22:07:27.906Z" }, + { url = "https://files.pythonhosted.org/packages/b7/35/7b30f4810fba112f60f5a43237545867504e15b1c7647a785fbaf588fac2/yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277", size = 95964, upload-time = "2026-03-01T22:07:30.198Z" }, + { url = "https://files.pythonhosted.org/packages/2d/86/ed7a73ab85ef00e8bb70b0cb5421d8a2a625b81a333941a469a6f4022828/yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4", size = 95882, upload-time = "2026-03-01T22:07:32.132Z" }, + { url = "https://files.pythonhosted.org/packages/19/90/d56967f61a29d8498efb7afb651e0b2b422a1e9b47b0ab5f4e40a19b699b/yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a", size = 90797, upload-time = "2026-03-01T22:07:34.404Z" }, + { url = "https://files.pythonhosted.org/packages/72/00/8b8f76909259f56647adb1011d7ed8b321bcf97e464515c65016a47ecdf0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb", size = 101023, upload-time = "2026-03-01T22:07:35.953Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e2/cab11b126fb7d440281b7df8e9ddbe4851e70a4dde47a202b6642586b8d9/yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41", size = 96227, upload-time = "2026-03-01T22:07:37.594Z" }, + { url = "https://files.pythonhosted.org/packages/c2/9b/2c893e16bfc50e6b2edf76c1a9eb6cb0c744346197e74c65e99ad8d634d0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2", size = 100302, upload-time = "2026-03-01T22:07:39.334Z" }, + { url = "https://files.pythonhosted.org/packages/28/ec/5498c4e3a6d5f1003beb23405671c2eb9cdbf3067d1c80f15eeafe301010/yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4", size = 98202, upload-time = "2026-03-01T22:07:41.717Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c3/cd737e2d45e70717907f83e146f6949f20cc23cd4bf7b2688727763aa458/yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4", size = 90558, upload-time = "2026-03-01T22:07:43.433Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/3774d162f6732d1cfb0b47b4140a942a35ca82bb19b6db1f80e9e7bdc8f8/yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2", size = 97610, upload-time = "2026-03-01T22:07:45.773Z" }, + { url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" }, + { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, ] [[package]] From 038f6a77d164c3e9cbe37fa89008d7a3bb2e1e58 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Mon, 2 Mar 2026 20:24:30 -0500 Subject: [PATCH 66/68] Linting --- src/pipecat/services/assemblyai/stt.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/pipecat/services/assemblyai/stt.py b/src/pipecat/services/assemblyai/stt.py index 659fa3b4d..c62ae959b 100644 --- a/src/pipecat/services/assemblyai/stt.py +++ b/src/pipecat/services/assemblyai/stt.py @@ -345,9 +345,7 @@ class AssemblyAISTTService(WebsocketSTTService): ): if conn_params.max_turn_silence is not None: update_config["max_turn_silence"] = conn_params.max_turn_silence - logger.info( - f"Updating max_turn_silence to: {conn_params.max_turn_silence}ms" - ) + logger.info(f"Updating max_turn_silence to: {conn_params.max_turn_silence}ms") if ( old_conn_params is None @@ -355,9 +353,7 @@ class AssemblyAISTTService(WebsocketSTTService): ): if conn_params.min_turn_silence is not None: update_config["min_turn_silence"] = conn_params.min_turn_silence - logger.info( - f"Updating min_turn_silence to: {conn_params.min_turn_silence}ms" - ) + logger.info(f"Updating min_turn_silence to: {conn_params.min_turn_silence}ms") # Send update if we have parameters to update if len(update_config) > 1: # More than just "type" From 252f17e1ca3e35acc35c9e0c6db4499f70d6bb59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleix=20Conchillo=20Flaqu=C3=A9?= Date: Mon, 2 Mar 2026 21:06:49 -0800 Subject: [PATCH 67/68] transport(tavus): fix on_joined callback --- src/pipecat/transports/tavus/transport.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/pipecat/transports/tavus/transport.py b/src/pipecat/transports/tavus/transport.py index cb6844250..79be070c5 100644 --- a/src/pipecat/transports/tavus/transport.py +++ b/src/pipecat/transports/tavus/transport.py @@ -134,12 +134,12 @@ class TavusCallbacks(BaseModel): """Callback handlers for Tavus events. Parameters: - on_connected: Called when the bot connects to the room. + on_joined: Called when the bot joins the Daily room. on_participant_joined: Called when a participant joins the conversation. on_participant_left: Called when a participant leaves the conversation. """ - on_connected: Callable[[Mapping[str, Any]], Awaitable[None]] + on_joined: Callable[[Mapping[str, Any]], Awaitable[None]] on_participant_joined: Callable[[Mapping[str, Any]], Awaitable[None]] on_participant_left: Callable[[Mapping[str, Any], str], Awaitable[None]] @@ -274,7 +274,7 @@ class TavusTransportClient: async def _on_joined(self, data): """Handle joined event.""" logger.debug("TavusTransportClient joined!") - await self._callbacks.on_connected(data) + await self._callbacks.on_joined(data) async def _on_left(self): """Handle left event.""" @@ -708,7 +708,7 @@ class TavusTransport(BaseTransport): self._params = params callbacks = TavusCallbacks( - on_connected=self._on_joined, + on_joined=self._on_joined, on_participant_joined=self._on_participant_joined, on_participant_left=self._on_participant_left, ) From 62260454a27898126a83374ea43f6cbc251da394 Mon Sep 17 00:00:00 2001 From: aconchillo <951761+aconchillo@users.noreply.github.com> Date: Tue, 3 Mar 2026 05:12:42 +0000 Subject: [PATCH 68/68] Update changelog for version 0.0.104 --- CHANGELOG.md | 383 +++++++++++++++++++++++++++++++++ changelog/3696.added.md | 1 - changelog/3696.changed.md | 1 - changelog/3696.deprecated.md | 1 - changelog/3714.added.md | 19 -- changelog/3714.changed.md | 1 - changelog/3714.deprecated.2.md | 1 - changelog/3714.deprecated.md | 3 - changelog/3759.performance.md | 1 - changelog/3764.added.md | 1 - changelog/3786.changed.md | 1 - changelog/3786.deprecated.md | 5 - changelog/3791.added.md | 1 - changelog/3794.fixed.md | 1 - changelog/3795.fixed.md | 1 - changelog/3803.fixed.md | 1 - changelog/3803.removed.md | 1 - changelog/3806.added.md | 1 - changelog/3806.changed.2.md | 1 - changelog/3806.changed.md | 1 - changelog/3807.changed.md | 1 - changelog/3808.fixed.md | 1 - changelog/3809.added.md | 1 - changelog/3809.changed.md | 1 - changelog/3809.deprecated.md | 1 - changelog/3811.changed.md | 1 - changelog/3813.fixed.md | 1 - changelog/3814.added.md | 1 - changelog/3814.fixed.md | 1 - changelog/3819.changed.md | 4 - changelog/3822.fixed.md | 1 - changelog/3825.fixed.md | 1 - changelog/3828.fixed.md | 1 - changelog/3837.fixed.md | 1 - changelog/3838.removed.md | 1 - changelog/3845.fixed.md | 1 - changelog/3850.fixed.md | 1 - changelog/3855.added.2.md | 1 - changelog/3855.added.3.md | 1 - changelog/3855.added.4.md | 1 - changelog/3855.added.md | 1 - changelog/3855.changed.md | 1 - changelog/3856.added.md | 1 - changelog/3856.changed.md | 1 - changelog/3856.fixed.md | 1 - changelog/3857.fixed.md | 1 - changelog/3863.added.2.md | 1 - changelog/3863.added.md | 1 - changelog/3863.changed.md | 1 - changelog/3863.deprecated.md | 1 - changelog/3865.changed.md | 1 - changelog/3867.fixed.md | 1 - changelog/3868.changed.md | 1 - changelog/3873.added.md | 1 - changelog/3879.changed.md | 1 - changelog/3881.added.2.md | 1 - changelog/3881.added.3.md | 1 - changelog/3881.added.md | 1 - changelog/3883.added.md | 1 - changelog/3885.added.2.md | 1 - changelog/3885.added.md | 1 - changelog/3886.other.md | 1 - changelog/3888.fixed.md | 1 - changelog/3893.fixed.md | 1 - changelog/3896.added.md | 1 - changelog/3896.changed.md | 1 - changelog/3896.deprecated.md | 1 - changelog/3902.changed.md | 1 - 68 files changed, 383 insertions(+), 94 deletions(-) delete mode 100644 changelog/3696.added.md delete mode 100644 changelog/3696.changed.md delete mode 100644 changelog/3696.deprecated.md delete mode 100644 changelog/3714.added.md delete mode 100644 changelog/3714.changed.md delete mode 100644 changelog/3714.deprecated.2.md delete mode 100644 changelog/3714.deprecated.md delete mode 100644 changelog/3759.performance.md delete mode 100644 changelog/3764.added.md delete mode 100644 changelog/3786.changed.md delete mode 100644 changelog/3786.deprecated.md delete mode 100644 changelog/3791.added.md delete mode 100644 changelog/3794.fixed.md delete mode 100644 changelog/3795.fixed.md delete mode 100644 changelog/3803.fixed.md delete mode 100644 changelog/3803.removed.md delete mode 100644 changelog/3806.added.md delete mode 100644 changelog/3806.changed.2.md delete mode 100644 changelog/3806.changed.md delete mode 100644 changelog/3807.changed.md delete mode 100644 changelog/3808.fixed.md delete mode 100644 changelog/3809.added.md delete mode 100644 changelog/3809.changed.md delete mode 100644 changelog/3809.deprecated.md delete mode 100644 changelog/3811.changed.md delete mode 100644 changelog/3813.fixed.md delete mode 100644 changelog/3814.added.md delete mode 100644 changelog/3814.fixed.md delete mode 100644 changelog/3819.changed.md delete mode 100644 changelog/3822.fixed.md delete mode 100644 changelog/3825.fixed.md delete mode 100644 changelog/3828.fixed.md delete mode 100644 changelog/3837.fixed.md delete mode 100644 changelog/3838.removed.md delete mode 100644 changelog/3845.fixed.md delete mode 100644 changelog/3850.fixed.md delete mode 100644 changelog/3855.added.2.md delete mode 100644 changelog/3855.added.3.md delete mode 100644 changelog/3855.added.4.md delete mode 100644 changelog/3855.added.md delete mode 100644 changelog/3855.changed.md delete mode 100644 changelog/3856.added.md delete mode 100644 changelog/3856.changed.md delete mode 100644 changelog/3856.fixed.md delete mode 100644 changelog/3857.fixed.md delete mode 100644 changelog/3863.added.2.md delete mode 100644 changelog/3863.added.md delete mode 100644 changelog/3863.changed.md delete mode 100644 changelog/3863.deprecated.md delete mode 100644 changelog/3865.changed.md delete mode 100644 changelog/3867.fixed.md delete mode 100644 changelog/3868.changed.md delete mode 100644 changelog/3873.added.md delete mode 100644 changelog/3879.changed.md delete mode 100644 changelog/3881.added.2.md delete mode 100644 changelog/3881.added.3.md delete mode 100644 changelog/3881.added.md delete mode 100644 changelog/3883.added.md delete mode 100644 changelog/3885.added.2.md delete mode 100644 changelog/3885.added.md delete mode 100644 changelog/3886.other.md delete mode 100644 changelog/3888.fixed.md delete mode 100644 changelog/3893.fixed.md delete mode 100644 changelog/3896.added.md delete mode 100644 changelog/3896.changed.md delete mode 100644 changelog/3896.deprecated.md delete mode 100644 changelog/3902.changed.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c917ec992..39dd6c194 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,389 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +## [0.0.104] - 2026-03-02 + +### Added + +- Added `TextAggregationMetricsData` metric measuring the time from the first + LLM token to the first complete sentence, representing the latency cost of + sentence aggregation in the TTS pipeline. + (PR [#3696](https://github.com/pipecat-ai/pipecat/pull/3696)) + +- Added support for using strongly-typed objects instead of dicts for updating + service settings at runtime. + + Instead of, say: + + ```python + await task.queue_frame( + STTUpdateSettingsFrame(settings={"language": Language.ES}) + ) + ``` + + you'd do: + + ```python + await task.queue_frame( + STTUpdateSettingsFrame(delta=DeepgramSTTSettings(language=Language.ES)) + ) + ``` + + Each service now vends strongly-typed classes like `DeepgramSTTSettings` + representing the service's runtime-updatable settings. + (PR [#3714](https://github.com/pipecat-ai/pipecat/pull/3714)) + +- Added support for specifying private endpoints for Azure Speech-to-Text, + enabling use in private networks behind firewalls. + (PR [#3764](https://github.com/pipecat-ai/pipecat/pull/3764)) + +- Added `LemonSliceTransport` and `LemonSliceApi` to support adding real-time + LemonSlice Avatars to any Daily room. + (PR [#3791](https://github.com/pipecat-ai/pipecat/pull/3791)) + +- Added `output_medium` parameter to `AgentInputParams` and + `OneShotInputParams` in Ultravox service to control initial output medium + (text or voice) at call creation time. + (PR [#3806](https://github.com/pipecat-ai/pipecat/pull/3806)) + +- Added `TurnMetricsData` as a generic metrics class for turn detection, with + e2e processing time measurement. `KrispVivaTurn` now emits `TurnMetricsData` + with `e2e_processing_time_ms` tracking the interval from VAD + speech-to-silence transition to turn completion. + (PR [#3809](https://github.com/pipecat-ai/pipecat/pull/3809)) + +- Added `on_audio_context_interrupted()` and `on_audio_context_completed()` + callbacks to `AudioContextTTSService`. Subclasses can override these to + perform provider-specific cleanup instead of overriding + `_handle_interruption()`. + (PR [#3814](https://github.com/pipecat-ai/pipecat/pull/3814)) + +- Added `on_summary_applied` event to `LLMContextSummarizer` for observability, + providing message counts before and after context summarization. + (PR [#3855](https://github.com/pipecat-ai/pipecat/pull/3855)) + +- Added `summary_message_template` to `LLMContextSummarizationConfig` for + customizing how summaries are formatted when injected into context (e.g., + wrapping in XML tags). + (PR [#3855](https://github.com/pipecat-ai/pipecat/pull/3855)) + +- Added `summarization_timeout` to `LLMContextSummarizationConfig` (default + 120s) to prevent hung LLM calls from permanently blocking future + summarizations. + (PR [#3855](https://github.com/pipecat-ai/pipecat/pull/3855)) + +- Added optional `llm` field to `LLMContextSummarizationConfig` for routing + summarization to a dedicated LLM service (e.g., a cheaper/faster model) + instead of the pipeline's primary model. + (PR [#3855](https://github.com/pipecat-ai/pipecat/pull/3855)) + +- Add AssemblyAI u3-rt-pro model support with built-in turn detection mode + (PR [#3856](https://github.com/pipecat-ai/pipecat/pull/3856)) + +- Added `LLMSummarizeContextFrame` to trigger on-demand context summarization + from anywhere in the pipeline (e.g. a function call tool). Accepts an + optional `config: LLMContextSummaryConfig` to override summary generation + settings per request. + (PR [#3863](https://github.com/pipecat-ai/pipecat/pull/3863)) + +- Added `LLMContextSummaryConfig` (summary generation params: + `target_context_tokens`, `min_messages_after_summary`, + `summarization_prompt`) and `LLMAutoContextSummarizationConfig` (auto-trigger + thresholds: `max_context_tokens`, `max_unsummarized_messages`, plus a nested + `summary_config`). These replace the monolithic + `LLMContextSummarizationConfig`. + (PR [#3863](https://github.com/pipecat-ai/pipecat/pull/3863)) + +- Added support for the `speed_alpha` parameter to the `arcana` model in + `RimeTTSService`. + (PR [#3873](https://github.com/pipecat-ai/pipecat/pull/3873)) + +- Added `ClientConnectedFrame`, a new `SystemFrame` pushed by all transports + (Daily, LiveKit, FastAPI WebSocket, WebSocket Server, SmallWebRTC, HeyGen, + Tavus) when a client connects. Enables observers to track transport readiness + timing. + (PR [#3881](https://github.com/pipecat-ai/pipecat/pull/3881)) + +- Added `StartupTimingObserver` for measuring how long each processor's + `start()` method takes during pipeline startup. Also measures transport + readiness — the time from `StartFrame` to first client connection — via the + `on_transport_timing_report` event. + (PR [#3881](https://github.com/pipecat-ai/pipecat/pull/3881)) + +- Added `BotConnectedFrame` for SFU transports and `on_transport_timing_report` + event to `StartupTimingObserver` with bot and client connection timing. + (PR [#3881](https://github.com/pipecat-ai/pipecat/pull/3881)) + +- Added optional `direction` parameter to `PipelineTask.queue_frame()` and + `PipelineTask.queue_frames()`, allowing frames to be pushed upstream from the + end of the pipeline. + (PR [#3883](https://github.com/pipecat-ai/pipecat/pull/3883)) + +- Added `on_latency_breakdown` event to `UserBotLatencyObserver` providing + per-service TTFB, text aggregation, user turn duration, and function call + latency metrics for each user-to-bot response cycle. + (PR [#3885](https://github.com/pipecat-ai/pipecat/pull/3885)) + +- Added `on_first_bot_speech_latency` event to `UserBotLatencyObserver` + measuring the time from client connection to first bot speech. An + `on_latency_breakdown` is also emitted for this first speech event. + (PR [#3885](https://github.com/pipecat-ai/pipecat/pull/3885)) + +- Added `broadcast_interruption()` to `FrameProcessor`. This method pushes an + `InterruptionFrame` both upstream and downstream directly from the calling + processor, avoiding the round-trip through the pipeline task that + `push_interruption_task_frame_and_wait()` required. + (PR [#3896](https://github.com/pipecat-ai/pipecat/pull/3896)) + +### Changed + +- Added `text_aggregation_mode` parameter to `TTSService` and all TTS + subclasses with a new `TextAggregationMode` enum (`SENTENCE`, `TOKEN`). All + text now flows through text aggregators regardless of mode, enabling pattern + detection and tag handling in TOKEN mode. + (PR [#3696](https://github.com/pipecat-ai/pipecat/pull/3696)) + +- ⚠️ Refactored runtime-updatable service settings to use strongly-typed + classes (`TTSSettings`, `STTSettings`, `LLMSettings`, and service-specific + subclasses) instead of plain dicts. Each service's `_settings` now holds + these strongly-typed objects. For service maintainers, see changes in + COMMUNITY_INTEGRATIONS.md. + (PR [#3714](https://github.com/pipecat-ai/pipecat/pull/3714)) + +- Word timestamp support has been moved from `WordTTSService` into `TTSService` + via a new `supports_word_timestamps` parameter. Services that previously + extended `WordTTSService`, `AudioContextWordTTSService`, or + `WebsocketWordTTSService` now pass `supports_word_timestamps=True` to their + parent `__init__` instead. + (PR [#3786](https://github.com/pipecat-ai/pipecat/pull/3786)) + +- Improved Ultravox TTFB measurement accuracy by using VAD speech end time + instead of `UserStoppedSpeakingFrame` timing. + (PR [#3806](https://github.com/pipecat-ai/pipecat/pull/3806)) + +- Aligned `UltravoxRealtimeLLMService` frame handling with OpenAI/Gemini + realtime services: added `InterruptionFrame` handling with metrics cleanup, + processing metrics at response boundaries, and improved agent transcript + handling for both voice and text output modalities. + (PR [#3806](https://github.com/pipecat-ai/pipecat/pull/3806)) + +- Updated `OpenAIRealtimeLLMService` default model to `gpt-realtime-1.5`. + (PR [#3807](https://github.com/pipecat-ai/pipecat/pull/3807)) + +- Added `api_key` parameter to `KrispVivaSDKManager`, `KrispVivaTurn`, and + `KrispVivaFilter` for Krisp SDK v1.6.1+ licensing. Falls back to + `KRISP_VIVA_API_KEY` environment variable. + (PR [#3809](https://github.com/pipecat-ai/pipecat/pull/3809)) + +- Bumped `nltk` minimum version from 3.9.1 to 3.9.3 to resolve a security + vulnerability. + (PR [#3811](https://github.com/pipecat-ai/pipecat/pull/3811)) + +- `ServiceSettingsUpdateFrame`s are now `UninterruptibleFrame`s. Generally + speaking, you don't want a user interruption to prevent a service setting + change from going into effect. Note that you usually don't use + `ServiceSettingsUpdateFrame` directly, you use one of its subclasses: + - `LLMUpdateSettingsFrame` + - `TTSUpdateSettingsFrame` + - `STTUpdateSettingsFrame` + (PR [#3819](https://github.com/pipecat-ai/pipecat/pull/3819)) + +- Updated context summarization to use `user` role instead of `assistant` for + summary messages. + (PR [#3855](https://github.com/pipecat-ai/pipecat/pull/3855)) + +- Rename `AssemblyAISTTService` parameter + `min_end_of_turn_silence_when_confident` parameter to `min_turn_silence` (old + name still supported with deprecation warning) + (PR [#3856](https://github.com/pipecat-ai/pipecat/pull/3856)) + +- ⚠️ Renamed `LLMAssistantAggregatorParams` fields: + `enable_context_summarization` → `enable_auto_context_summarization` and + `context_summarization_config` → `auto_context_summarization_config` (now + accepts `LLMAutoContextSummarizationConfig`). The old names still work with a + `DeprecationWarning` for one release cycle. + (PR [#3863](https://github.com/pipecat-ai/pipecat/pull/3863)) + +- `ElevenLabsRealtimeSTTService` now sets `TranscriptionFrame.finalized` to + `True` when using `CommitStrategy.MANUAL`. + (PR [#3865](https://github.com/pipecat-ai/pipecat/pull/3865)) + +- Updated numba version pin from == to >=0.61.2 + (PR [#3868](https://github.com/pipecat-ai/pipecat/pull/3868)) + +- Updated tracing code to use `ServiceSettings` dataclass API + (`given_fields()`, attribute access) instead of dict-style access + (`.items()`, `in`, subscript). + (PR [#3879](https://github.com/pipecat-ai/pipecat/pull/3879)) + +- ⚠️ Removed `event` field and `complete()` method from `InterruptionFrame`. + Removed `event` field from `InterruptionTaskFrame`. These are no longer + needed since `broadcast_interruption()` does not require a round-trip + completion signal. + (PR [#3896](https://github.com/pipecat-ai/pipecat/pull/3896)) + +- Moved `pipecat.services.deepgram.stt_sagemaker` and + `pipecat.services.deepgram.tts_sagemaker` to + `pipecat.services.deepgram.sagemaker.stt` and + `pipecat.services.deepgram.sagemaker.tts`. The old import paths still work + but emit a `DeprecationWarning`. + (PR [#3902](https://github.com/pipecat-ai/pipecat/pull/3902)) + +### Deprecated + +- ⚠️ Deprecated `aggregate_sentences` parameter on `TTSService` and all TTS + subclasses. Use `text_aggregation_mode=TextAggregationMode.SENTENCE` or + `text_aggregation_mode=TextAggregationMode.TOKEN` instead. + (PR [#3696](https://github.com/pipecat-ai/pipecat/pull/3696)) + +- Deprecated `set_model()`, `set_voice()`, and `set_language()` on AI services + in favor of runtime updates via `TTSUpdateSettingsFrame`, + `STTUpdateSettingsFrame`, and `LLMUpdateSettingsFrame`. + + ⚠️ Note, too, a subtle behavior change in these deprecated methods. Whereas + previously only `set_language()` caused the service to actually react to the + update (e.g. by reconnecting to a remote service so it an pick up the + change), now all these methods do. This change was made as part of a refactor + making them all work the same way under the hood. + (PR [#3714](https://github.com/pipecat-ai/pipecat/pull/3714)) + +- Dict-based `*UpdateSettingsFrame(settings={...})` is deprecated in favor of + passing typed settings delta objects with + `*UpdateSettingsFrame(delta={...})`. + (PR [#3714](https://github.com/pipecat-ai/pipecat/pull/3714)) + +- Deprecated `WordTTSService`, `WebsocketWordTTSService`, + `AudioContextWordTTSService`, and `InterruptibleWordTTSService`. Use their + non-word counterparts with `supports_word_timestamps=True` instead: + - `WordTTSService` → `TTSService(supports_word_timestamps=True)` + - `WebsocketWordTTSService` → + `WebsocketTTSService(supports_word_timestamps=True)` + - `AudioContextWordTTSService` → + `AudioContextTTSService(supports_word_timestamps=True)` + - `InterruptibleWordTTSService` → + `InterruptibleTTSService(supports_word_timestamps=True)` + (PR [#3786](https://github.com/pipecat-ai/pipecat/pull/3786)) + +- Deprecated `SmartTurnMetricsData` in favor of `TurnMetricsData`. + `BaseSmartTurn` now emits `TurnMetricsData` directly. + (PR [#3809](https://github.com/pipecat-ai/pipecat/pull/3809)) + +- Deprecated `LLMContextSummarizationConfig`. Use + `LLMAutoContextSummarizationConfig` with a nested `LLMContextSummaryConfig` + instead. The old class emits a `DeprecationWarning`. + (PR [#3863](https://github.com/pipecat-ai/pipecat/pull/3863)) + +- Deprecated `push_interruption_task_frame_and_wait()` in `FrameProcessor`. Use + `broadcast_interruption()` instead. The old method now delegates to + `broadcast_interruption()` and logs a deprecation warning. + (PR [#3896](https://github.com/pipecat-ai/pipecat/pull/3896)) + +### Removed + +- Removed `local-smart-turn-v3` optional extra from `pyproject.toml`. The + `transformers` and `onnxruntime` packages are now always installed as core + dependencies since they are required by the default turn stop strategy, + `TurnAnalyzerUserTurnStopStrategy` which uses `LocalSmartTurnAnalyzerV3`. + (PR [#3803](https://github.com/pipecat-ai/pipecat/pull/3803)) + +- ⚠️ Removed `PlayHTTTSService` and `PlayHTHttpTTSService`. PlayHT has been + shut down and is no longer available. + (PR [#3838](https://github.com/pipecat-ai/pipecat/pull/3838)) + +### Fixed + +- Added `LLMSpecificMessage` handling in `LLMContextSummarizationUtil` to skip + provider-specific messages during context summarization. + (PR [#3794](https://github.com/pipecat-ai/pipecat/pull/3794)) + +- Treated `response_cancel_not_active` as a non-fatal error in realtime + services (`OpenAIRealtimeLLMService`, `GrokRealtimeLLMService`, + `OpenAIRealtimeBetaLLMService`) to prevent WebSocket disconnection when + cancelling an inactive response. + (PR [#3795](https://github.com/pipecat-ai/pipecat/pull/3795)) + +- Fixed Poetry compatibility by inlining `local-smart-turn-v3` dependencies + (`transformers`, `onnxruntime`) into core dependencies instead of using a + self-referential extra. + (PR [#3803](https://github.com/pipecat-ai/pipecat/pull/3803)) + +- Fixed `SentryMetrics` method signatures to match updated + `FrameProcessorMetrics` base class, resolving `TypeError` when using + `start_time`/`end_time` keyword arguments. + (PR [#3808](https://github.com/pipecat-ai/pipecat/pull/3808)) + +- Fixed STT TTFB metrics not being reported for `SonioxSTTService` and + `AWSTranscribeSTTService` due to missing `can_generate_metrics()` override. + (PR [#3813](https://github.com/pipecat-ai/pipecat/pull/3813)) + +- Fixed an issue where `AudioContextTTSService`-based providers (AsyncAI, + ElevenLabs, Inworld, Rime) did not close or clean up their server-side audio + contexts after normal speech completion, only on interruption. + (PR [#3814](https://github.com/pipecat-ai/pipecat/pull/3814)) + +- Fixed STT TTFB metrics measuring timeout expiry time instead of actual + transcript arrival time. + (PR [#3822](https://github.com/pipecat-ai/pipecat/pull/3822)) + +- Fixed `InterimTranscriptionFrame` and `TranslationFrame` being + unintentionally pushed downstream in `LLMUserAggregator`. They are now + consumed like `TranscriptionFrame`. + (PR [#3825](https://github.com/pipecat-ai/pipecat/pull/3825)) + +- Fixed misleading "Empty audio frame received for STT service" warnings when + using audio filters (e.g. `RNNoiseFilter`, `KrispVivaFilter`, `AICFilter`) + that buffer audio internally. + (PR [#3828](https://github.com/pipecat-ai/pipecat/pull/3828)) + +- Fixed issues with `RimeNonJsonTTSService` where trailing punctuation is + sometimes vocalized + (PR [#3837](https://github.com/pipecat-ai/pipecat/pull/3837)) + +- Fixed `TTSSpeakFrame` not committing spoken text to the conversation context + when used outside of an LLM response (e.g., bot greetings or injected + speech). + (PR [#3845](https://github.com/pipecat-ai/pipecat/pull/3845)) + +- Removed verbose per-chunk audio logging from `GenesysAudioHookSerializer` + that flooded production logs. + (PR [#3850](https://github.com/pipecat-ai/pipecat/pull/3850)) + +- Add beta feature warning when using custom prompts with AssemblyAI + (PR [#3856](https://github.com/pipecat-ai/pipecat/pull/3856)) + +- Fixed `LocalSmartTurnAnalyzerV3` producing incorrect end-of-turn predictions + at non-16kHz sample rates (e.g. 8kHz Twilio telephony) by adding automatic + resampling to 16kHz before Whisper feature extraction. + (PR [#3857](https://github.com/pipecat-ai/pipecat/pull/3857)) + +- Fixed `PipelineTask` double-inserting `RTVIProcessor` into the frame chain + when the user provides both an `RTVIProcessor` in the pipeline and a custom + `RTVIObserver` subclass in observers. + (PR [#3867](https://github.com/pipecat-ai/pipecat/pull/3867)) + +- Fixed turn completion instructions being lost when `LLMMessagesUpdateFrame` + replaces the LLM context. When `filter_incomplete_user_turns` is enabled, the + turn completion system message is now re-injected after context replacement. + (PR [#3888](https://github.com/pipecat-ai/pipecat/pull/3888)) + +- Fixed Azure TTS and STT services silently swallowing cancellation errors + (invalid API key, network failures, rate limiting) instead of propagating + them as `ErrorFrame`s to the pipeline. + (PR [#3893](https://github.com/pipecat-ai/pipecat/pull/3893)) + +### Performance + +- Switched `GradiumTTSService` from `InterruptibleWordTTSService` to + `AudioContextWordTTSService`, eliminating websocket disconnect/reconnect on + every interruption by using `client_req_id`-based multiplexing. + (PR [#3759](https://github.com/pipecat-ai/pipecat/pull/3759)) + +### Other + +- Standardized Sarvam STT/TTS User-Agent header handling to consistently send + Pipecat SDK identity in websocket requests. + (PR [#3886](https://github.com/pipecat-ai/pipecat/pull/3886)) + ## [0.0.103] - 2026-02-20 ### Added diff --git a/changelog/3696.added.md b/changelog/3696.added.md deleted file mode 100644 index 39726d930..000000000 --- a/changelog/3696.added.md +++ /dev/null @@ -1 +0,0 @@ -- Added `TextAggregationMetricsData` metric measuring the time from the first LLM token to the first complete sentence, representing the latency cost of sentence aggregation in the TTS pipeline. diff --git a/changelog/3696.changed.md b/changelog/3696.changed.md deleted file mode 100644 index a495560ba..000000000 --- a/changelog/3696.changed.md +++ /dev/null @@ -1 +0,0 @@ -- Added `text_aggregation_mode` parameter to `TTSService` and all TTS subclasses with a new `TextAggregationMode` enum (`SENTENCE`, `TOKEN`). All text now flows through text aggregators regardless of mode, enabling pattern detection and tag handling in TOKEN mode. diff --git a/changelog/3696.deprecated.md b/changelog/3696.deprecated.md deleted file mode 100644 index 7b371fc21..000000000 --- a/changelog/3696.deprecated.md +++ /dev/null @@ -1 +0,0 @@ -- ⚠️ Deprecated `aggregate_sentences` parameter on `TTSService` and all TTS subclasses. Use `text_aggregation_mode=TextAggregationMode.SENTENCE` or `text_aggregation_mode=TextAggregationMode.TOKEN` instead. diff --git a/changelog/3714.added.md b/changelog/3714.added.md deleted file mode 100644 index efa54b7d5..000000000 --- a/changelog/3714.added.md +++ /dev/null @@ -1,19 +0,0 @@ -- Added support for using strongly-typed objects instead of dicts for updating service settings at runtime. - - Instead of, say: - - ```python - await task.queue_frame( - STTUpdateSettingsFrame(settings={"language": Language.ES}) - ) - ``` - - you'd do: - - ```python - await task.queue_frame( - STTUpdateSettingsFrame(delta=DeepgramSTTSettings(language=Language.ES)) - ) - ``` - - Each service now vends strongly-typed classes like `DeepgramSTTSettings` representing the service's runtime-updatable settings. diff --git a/changelog/3714.changed.md b/changelog/3714.changed.md deleted file mode 100644 index bcfb5cbf7..000000000 --- a/changelog/3714.changed.md +++ /dev/null @@ -1 +0,0 @@ -- ⚠️ Refactored runtime-updatable service settings to use strongly-typed classes (`TTSSettings`, `STTSettings`, `LLMSettings`, and service-specific subclasses) instead of plain dicts. Each service's `_settings` now holds these strongly-typed objects. For service maintainers, see changes in COMMUNITY_INTEGRATIONS.md. diff --git a/changelog/3714.deprecated.2.md b/changelog/3714.deprecated.2.md deleted file mode 100644 index d386fa5a4..000000000 --- a/changelog/3714.deprecated.2.md +++ /dev/null @@ -1 +0,0 @@ -- Dict-based `*UpdateSettingsFrame(settings={...})` is deprecated in favor of passing typed settings delta objects with `*UpdateSettingsFrame(delta={...})`. diff --git a/changelog/3714.deprecated.md b/changelog/3714.deprecated.md deleted file mode 100644 index 75337a642..000000000 --- a/changelog/3714.deprecated.md +++ /dev/null @@ -1,3 +0,0 @@ -- Deprecated `set_model()`, `set_voice()`, and `set_language()` on AI services in favor of runtime updates via `TTSUpdateSettingsFrame`, `STTUpdateSettingsFrame`, and `LLMUpdateSettingsFrame`. - - ⚠️ Note, too, a subtle behavior change in these deprecated methods. Whereas previously only `set_language()` caused the service to actually react to the update (e.g. by reconnecting to a remote service so it an pick up the change), now all these methods do. This change was made as part of a refactor making them all work the same way under the hood. diff --git a/changelog/3759.performance.md b/changelog/3759.performance.md deleted file mode 100644 index 1bdc17a17..000000000 --- a/changelog/3759.performance.md +++ /dev/null @@ -1 +0,0 @@ -- Switched `GradiumTTSService` from `InterruptibleWordTTSService` to `AudioContextWordTTSService`, eliminating websocket disconnect/reconnect on every interruption by using `client_req_id`-based multiplexing. diff --git a/changelog/3764.added.md b/changelog/3764.added.md deleted file mode 100644 index 5da82f0c1..000000000 --- a/changelog/3764.added.md +++ /dev/null @@ -1 +0,0 @@ -- Added support for specifying private endpoints for Azure Speech-to-Text, enabling use in private networks behind firewalls. \ No newline at end of file diff --git a/changelog/3786.changed.md b/changelog/3786.changed.md deleted file mode 100644 index ed8e7e444..000000000 --- a/changelog/3786.changed.md +++ /dev/null @@ -1 +0,0 @@ -- Word timestamp support has been moved from `WordTTSService` into `TTSService` via a new `supports_word_timestamps` parameter. Services that previously extended `WordTTSService`, `AudioContextWordTTSService`, or `WebsocketWordTTSService` now pass `supports_word_timestamps=True` to their parent `__init__` instead. diff --git a/changelog/3786.deprecated.md b/changelog/3786.deprecated.md deleted file mode 100644 index 7ac5a5b9c..000000000 --- a/changelog/3786.deprecated.md +++ /dev/null @@ -1,5 +0,0 @@ -- Deprecated `WordTTSService`, `WebsocketWordTTSService`, `AudioContextWordTTSService`, and `InterruptibleWordTTSService`. Use their non-word counterparts with `supports_word_timestamps=True` instead: - - `WordTTSService` → `TTSService(supports_word_timestamps=True)` - - `WebsocketWordTTSService` → `WebsocketTTSService(supports_word_timestamps=True)` - - `AudioContextWordTTSService` → `AudioContextTTSService(supports_word_timestamps=True)` - - `InterruptibleWordTTSService` → `InterruptibleTTSService(supports_word_timestamps=True)` diff --git a/changelog/3791.added.md b/changelog/3791.added.md deleted file mode 100644 index 89767de5e..000000000 --- a/changelog/3791.added.md +++ /dev/null @@ -1 +0,0 @@ -- Added `LemonSliceTransport` and `LemonSliceApi` to support adding real-time LemonSlice Avatars to any Daily room. \ No newline at end of file diff --git a/changelog/3794.fixed.md b/changelog/3794.fixed.md deleted file mode 100644 index e2b3c7c00..000000000 --- a/changelog/3794.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Added `LLMSpecificMessage` handling in `LLMContextSummarizationUtil` to skip provider-specific messages during context summarization. diff --git a/changelog/3795.fixed.md b/changelog/3795.fixed.md deleted file mode 100644 index 8c231abac..000000000 --- a/changelog/3795.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Treated `response_cancel_not_active` as a non-fatal error in realtime services (`OpenAIRealtimeLLMService`, `GrokRealtimeLLMService`, `OpenAIRealtimeBetaLLMService`) to prevent WebSocket disconnection when cancelling an inactive response. \ No newline at end of file diff --git a/changelog/3803.fixed.md b/changelog/3803.fixed.md deleted file mode 100644 index 73d7c3f19..000000000 --- a/changelog/3803.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed Poetry compatibility by inlining `local-smart-turn-v3` dependencies (`transformers`, `onnxruntime`) into core dependencies instead of using a self-referential extra. diff --git a/changelog/3803.removed.md b/changelog/3803.removed.md deleted file mode 100644 index 867c3cfcc..000000000 --- a/changelog/3803.removed.md +++ /dev/null @@ -1 +0,0 @@ -- Removed `local-smart-turn-v3` optional extra from `pyproject.toml`. The `transformers` and `onnxruntime` packages are now always installed as core dependencies since they are required by the default turn stop strategy, `TurnAnalyzerUserTurnStopStrategy` which uses `LocalSmartTurnAnalyzerV3`. diff --git a/changelog/3806.added.md b/changelog/3806.added.md deleted file mode 100644 index eeddc9825..000000000 --- a/changelog/3806.added.md +++ /dev/null @@ -1 +0,0 @@ -- Added `output_medium` parameter to `AgentInputParams` and `OneShotInputParams` in Ultravox service to control initial output medium (text or voice) at call creation time. diff --git a/changelog/3806.changed.2.md b/changelog/3806.changed.2.md deleted file mode 100644 index 9d6dfdf76..000000000 --- a/changelog/3806.changed.2.md +++ /dev/null @@ -1 +0,0 @@ -- Improved Ultravox TTFB measurement accuracy by using VAD speech end time instead of `UserStoppedSpeakingFrame` timing. diff --git a/changelog/3806.changed.md b/changelog/3806.changed.md deleted file mode 100644 index c8e2fb68c..000000000 --- a/changelog/3806.changed.md +++ /dev/null @@ -1 +0,0 @@ -- Aligned `UltravoxRealtimeLLMService` frame handling with OpenAI/Gemini realtime services: added `InterruptionFrame` handling with metrics cleanup, processing metrics at response boundaries, and improved agent transcript handling for both voice and text output modalities. diff --git a/changelog/3807.changed.md b/changelog/3807.changed.md deleted file mode 100644 index cc99f29fb..000000000 --- a/changelog/3807.changed.md +++ /dev/null @@ -1 +0,0 @@ -- Updated `OpenAIRealtimeLLMService` default model to `gpt-realtime-1.5`. \ No newline at end of file diff --git a/changelog/3808.fixed.md b/changelog/3808.fixed.md deleted file mode 100644 index 6bf105bf6..000000000 --- a/changelog/3808.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed `SentryMetrics` method signatures to match updated `FrameProcessorMetrics` base class, resolving `TypeError` when using `start_time`/`end_time` keyword arguments. diff --git a/changelog/3809.added.md b/changelog/3809.added.md deleted file mode 100644 index 99047dc76..000000000 --- a/changelog/3809.added.md +++ /dev/null @@ -1 +0,0 @@ -- Added `TurnMetricsData` as a generic metrics class for turn detection, with e2e processing time measurement. `KrispVivaTurn` now emits `TurnMetricsData` with `e2e_processing_time_ms` tracking the interval from VAD speech-to-silence transition to turn completion. diff --git a/changelog/3809.changed.md b/changelog/3809.changed.md deleted file mode 100644 index 479eaf6ed..000000000 --- a/changelog/3809.changed.md +++ /dev/null @@ -1 +0,0 @@ -- Added `api_key` parameter to `KrispVivaSDKManager`, `KrispVivaTurn`, and `KrispVivaFilter` for Krisp SDK v1.6.1+ licensing. Falls back to `KRISP_VIVA_API_KEY` environment variable. diff --git a/changelog/3809.deprecated.md b/changelog/3809.deprecated.md deleted file mode 100644 index f1498ec0b..000000000 --- a/changelog/3809.deprecated.md +++ /dev/null @@ -1 +0,0 @@ -- Deprecated `SmartTurnMetricsData` in favor of `TurnMetricsData`. `BaseSmartTurn` now emits `TurnMetricsData` directly. diff --git a/changelog/3811.changed.md b/changelog/3811.changed.md deleted file mode 100644 index eb3eb492e..000000000 --- a/changelog/3811.changed.md +++ /dev/null @@ -1 +0,0 @@ -- Bumped `nltk` minimum version from 3.9.1 to 3.9.3 to resolve a security vulnerability. diff --git a/changelog/3813.fixed.md b/changelog/3813.fixed.md deleted file mode 100644 index 9d9115e77..000000000 --- a/changelog/3813.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed STT TTFB metrics not being reported for `SonioxSTTService` and `AWSTranscribeSTTService` due to missing `can_generate_metrics()` override. diff --git a/changelog/3814.added.md b/changelog/3814.added.md deleted file mode 100644 index b6b2ebbf8..000000000 --- a/changelog/3814.added.md +++ /dev/null @@ -1 +0,0 @@ -- Added `on_audio_context_interrupted()` and `on_audio_context_completed()` callbacks to `AudioContextTTSService`. Subclasses can override these to perform provider-specific cleanup instead of overriding `_handle_interruption()`. diff --git a/changelog/3814.fixed.md b/changelog/3814.fixed.md deleted file mode 100644 index ecd4871f6..000000000 --- a/changelog/3814.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed an issue where `AudioContextTTSService`-based providers (AsyncAI, ElevenLabs, Inworld, Rime) did not close or clean up their server-side audio contexts after normal speech completion, only on interruption. diff --git a/changelog/3819.changed.md b/changelog/3819.changed.md deleted file mode 100644 index 7b43c399c..000000000 --- a/changelog/3819.changed.md +++ /dev/null @@ -1,4 +0,0 @@ -- `ServiceSettingsUpdateFrame`s are now `UninterruptibleFrame`s. Generally speaking, you don't want a user interruption to prevent a service setting change from going into effect. Note that you usually don't use `ServiceSettingsUpdateFrame` directly, you use one of its subclasses: - - `LLMUpdateSettingsFrame` - - `TTSUpdateSettingsFrame` - - `STTUpdateSettingsFrame` diff --git a/changelog/3822.fixed.md b/changelog/3822.fixed.md deleted file mode 100644 index 48218845f..000000000 --- a/changelog/3822.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed STT TTFB metrics measuring timeout expiry time instead of actual transcript arrival time. \ No newline at end of file diff --git a/changelog/3825.fixed.md b/changelog/3825.fixed.md deleted file mode 100644 index 7cd9ba508..000000000 --- a/changelog/3825.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed `InterimTranscriptionFrame` and `TranslationFrame` being unintentionally pushed downstream in `LLMUserAggregator`. They are now consumed like `TranscriptionFrame`. diff --git a/changelog/3828.fixed.md b/changelog/3828.fixed.md deleted file mode 100644 index dd2ee257d..000000000 --- a/changelog/3828.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed misleading "Empty audio frame received for STT service" warnings when using audio filters (e.g. `RNNoiseFilter`, `KrispVivaFilter`, `AICFilter`) that buffer audio internally. diff --git a/changelog/3837.fixed.md b/changelog/3837.fixed.md deleted file mode 100644 index 767e79f45..000000000 --- a/changelog/3837.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed issues with `RimeNonJsonTTSService` where trailing punctuation is sometimes vocalized diff --git a/changelog/3838.removed.md b/changelog/3838.removed.md deleted file mode 100644 index fa811cb71..000000000 --- a/changelog/3838.removed.md +++ /dev/null @@ -1 +0,0 @@ -- ⚠️ Removed `PlayHTTTSService` and `PlayHTHttpTTSService`. PlayHT has been shut down and is no longer available. diff --git a/changelog/3845.fixed.md b/changelog/3845.fixed.md deleted file mode 100644 index 423853700..000000000 --- a/changelog/3845.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed `TTSSpeakFrame` not committing spoken text to the conversation context when used outside of an LLM response (e.g., bot greetings or injected speech). \ No newline at end of file diff --git a/changelog/3850.fixed.md b/changelog/3850.fixed.md deleted file mode 100644 index cfbdc6cf7..000000000 --- a/changelog/3850.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Removed verbose per-chunk audio logging from `GenesysAudioHookSerializer` that flooded production logs. diff --git a/changelog/3855.added.2.md b/changelog/3855.added.2.md deleted file mode 100644 index 01cd23efe..000000000 --- a/changelog/3855.added.2.md +++ /dev/null @@ -1 +0,0 @@ -- Added optional `llm` field to `LLMContextSummarizationConfig` for routing summarization to a dedicated LLM service (e.g., a cheaper/faster model) instead of the pipeline's primary model. diff --git a/changelog/3855.added.3.md b/changelog/3855.added.3.md deleted file mode 100644 index b93fdec60..000000000 --- a/changelog/3855.added.3.md +++ /dev/null @@ -1 +0,0 @@ -- Added `summarization_timeout` to `LLMContextSummarizationConfig` (default 120s) to prevent hung LLM calls from permanently blocking future summarizations. diff --git a/changelog/3855.added.4.md b/changelog/3855.added.4.md deleted file mode 100644 index b712b4ac9..000000000 --- a/changelog/3855.added.4.md +++ /dev/null @@ -1 +0,0 @@ -- Added `on_summary_applied` event to `LLMContextSummarizer` for observability, providing message counts before and after context summarization. diff --git a/changelog/3855.added.md b/changelog/3855.added.md deleted file mode 100644 index 79d37eeba..000000000 --- a/changelog/3855.added.md +++ /dev/null @@ -1 +0,0 @@ -- Added `summary_message_template` to `LLMContextSummarizationConfig` for customizing how summaries are formatted when injected into context (e.g., wrapping in XML tags). diff --git a/changelog/3855.changed.md b/changelog/3855.changed.md deleted file mode 100644 index 2eac6785a..000000000 --- a/changelog/3855.changed.md +++ /dev/null @@ -1 +0,0 @@ -- Updated context summarization to use `user` role instead of `assistant` for summary messages. diff --git a/changelog/3856.added.md b/changelog/3856.added.md deleted file mode 100644 index 8074a5281..000000000 --- a/changelog/3856.added.md +++ /dev/null @@ -1 +0,0 @@ -- Add AssemblyAI u3-rt-pro model support with built-in turn detection mode diff --git a/changelog/3856.changed.md b/changelog/3856.changed.md deleted file mode 100644 index 1e7f4c916..000000000 --- a/changelog/3856.changed.md +++ /dev/null @@ -1 +0,0 @@ -- Rename `AssemblyAISTTService` parameter `min_end_of_turn_silence_when_confident` parameter to `min_turn_silence` (old name still supported with deprecation warning) diff --git a/changelog/3856.fixed.md b/changelog/3856.fixed.md deleted file mode 100644 index c31fe8ddf..000000000 --- a/changelog/3856.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Add beta feature warning when using custom prompts with AssemblyAI diff --git a/changelog/3857.fixed.md b/changelog/3857.fixed.md deleted file mode 100644 index 869c54111..000000000 --- a/changelog/3857.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed `LocalSmartTurnAnalyzerV3` producing incorrect end-of-turn predictions at non-16kHz sample rates (e.g. 8kHz Twilio telephony) by adding automatic resampling to 16kHz before Whisper feature extraction. diff --git a/changelog/3863.added.2.md b/changelog/3863.added.2.md deleted file mode 100644 index 9c0ab90ba..000000000 --- a/changelog/3863.added.2.md +++ /dev/null @@ -1 +0,0 @@ -- Added `LLMContextSummaryConfig` (summary generation params: `target_context_tokens`, `min_messages_after_summary`, `summarization_prompt`) and `LLMAutoContextSummarizationConfig` (auto-trigger thresholds: `max_context_tokens`, `max_unsummarized_messages`, plus a nested `summary_config`). These replace the monolithic `LLMContextSummarizationConfig`. diff --git a/changelog/3863.added.md b/changelog/3863.added.md deleted file mode 100644 index d6214aed0..000000000 --- a/changelog/3863.added.md +++ /dev/null @@ -1 +0,0 @@ -- Added `LLMSummarizeContextFrame` to trigger on-demand context summarization from anywhere in the pipeline (e.g. a function call tool). Accepts an optional `config: LLMContextSummaryConfig` to override summary generation settings per request. diff --git a/changelog/3863.changed.md b/changelog/3863.changed.md deleted file mode 100644 index faf5712d8..000000000 --- a/changelog/3863.changed.md +++ /dev/null @@ -1 +0,0 @@ -- ⚠️ Renamed `LLMAssistantAggregatorParams` fields: `enable_context_summarization` → `enable_auto_context_summarization` and `context_summarization_config` → `auto_context_summarization_config` (now accepts `LLMAutoContextSummarizationConfig`). The old names still work with a `DeprecationWarning` for one release cycle. diff --git a/changelog/3863.deprecated.md b/changelog/3863.deprecated.md deleted file mode 100644 index ba2311fbd..000000000 --- a/changelog/3863.deprecated.md +++ /dev/null @@ -1 +0,0 @@ -- Deprecated `LLMContextSummarizationConfig`. Use `LLMAutoContextSummarizationConfig` with a nested `LLMContextSummaryConfig` instead. The old class emits a `DeprecationWarning`. diff --git a/changelog/3865.changed.md b/changelog/3865.changed.md deleted file mode 100644 index 7a70eb0d7..000000000 --- a/changelog/3865.changed.md +++ /dev/null @@ -1 +0,0 @@ -- `ElevenLabsRealtimeSTTService` now sets `TranscriptionFrame.finalized` to `True` when using `CommitStrategy.MANUAL`. diff --git a/changelog/3867.fixed.md b/changelog/3867.fixed.md deleted file mode 100644 index 41ee584a2..000000000 --- a/changelog/3867.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed `PipelineTask` double-inserting `RTVIProcessor` into the frame chain when the user provides both an `RTVIProcessor` in the pipeline and a custom `RTVIObserver` subclass in observers. diff --git a/changelog/3868.changed.md b/changelog/3868.changed.md deleted file mode 100644 index 4f019cca2..000000000 --- a/changelog/3868.changed.md +++ /dev/null @@ -1 +0,0 @@ -- Updated numba version pin from == to >=0.61.2 diff --git a/changelog/3873.added.md b/changelog/3873.added.md deleted file mode 100644 index ed01b8e5d..000000000 --- a/changelog/3873.added.md +++ /dev/null @@ -1 +0,0 @@ -- Added support for the `speed_alpha` parameter to the `arcana` model in `RimeTTSService`. diff --git a/changelog/3879.changed.md b/changelog/3879.changed.md deleted file mode 100644 index 2b69f63ce..000000000 --- a/changelog/3879.changed.md +++ /dev/null @@ -1 +0,0 @@ -- Updated tracing code to use `ServiceSettings` dataclass API (`given_fields()`, attribute access) instead of dict-style access (`.items()`, `in`, subscript). diff --git a/changelog/3881.added.2.md b/changelog/3881.added.2.md deleted file mode 100644 index a5bda94c1..000000000 --- a/changelog/3881.added.2.md +++ /dev/null @@ -1 +0,0 @@ -- Added `ClientConnectedFrame`, a new `SystemFrame` pushed by all transports (Daily, LiveKit, FastAPI WebSocket, WebSocket Server, SmallWebRTC, HeyGen, Tavus) when a client connects. Enables observers to track transport readiness timing. diff --git a/changelog/3881.added.3.md b/changelog/3881.added.3.md deleted file mode 100644 index cad26e876..000000000 --- a/changelog/3881.added.3.md +++ /dev/null @@ -1 +0,0 @@ -Added `BotConnectedFrame` for SFU transports and `on_transport_timing_report` event to `StartupTimingObserver` with bot and client connection timing. diff --git a/changelog/3881.added.md b/changelog/3881.added.md deleted file mode 100644 index c71475675..000000000 --- a/changelog/3881.added.md +++ /dev/null @@ -1 +0,0 @@ -- Added `StartupTimingObserver` for measuring how long each processor's `start()` method takes during pipeline startup. Also measures transport readiness — the time from `StartFrame` to first client connection — via the `on_transport_timing_report` event. diff --git a/changelog/3883.added.md b/changelog/3883.added.md deleted file mode 100644 index 84360a891..000000000 --- a/changelog/3883.added.md +++ /dev/null @@ -1 +0,0 @@ -- Added optional `direction` parameter to `PipelineTask.queue_frame()` and `PipelineTask.queue_frames()`, allowing frames to be pushed upstream from the end of the pipeline. diff --git a/changelog/3885.added.2.md b/changelog/3885.added.2.md deleted file mode 100644 index 5a6adce12..000000000 --- a/changelog/3885.added.2.md +++ /dev/null @@ -1 +0,0 @@ -- Added `on_first_bot_speech_latency` event to `UserBotLatencyObserver` measuring the time from client connection to first bot speech. An `on_latency_breakdown` is also emitted for this first speech event. diff --git a/changelog/3885.added.md b/changelog/3885.added.md deleted file mode 100644 index 96f8cc2cd..000000000 --- a/changelog/3885.added.md +++ /dev/null @@ -1 +0,0 @@ -- Added `on_latency_breakdown` event to `UserBotLatencyObserver` providing per-service TTFB, text aggregation, user turn duration, and function call latency metrics for each user-to-bot response cycle. diff --git a/changelog/3886.other.md b/changelog/3886.other.md deleted file mode 100644 index 0e9fdafed..000000000 --- a/changelog/3886.other.md +++ /dev/null @@ -1 +0,0 @@ -- Standardized Sarvam STT/TTS User-Agent header handling to consistently send Pipecat SDK identity in websocket requests. \ No newline at end of file diff --git a/changelog/3888.fixed.md b/changelog/3888.fixed.md deleted file mode 100644 index 99e9ad0e0..000000000 --- a/changelog/3888.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed turn completion instructions being lost when `LLMMessagesUpdateFrame` replaces the LLM context. When `filter_incomplete_user_turns` is enabled, the turn completion system message is now re-injected after context replacement. diff --git a/changelog/3893.fixed.md b/changelog/3893.fixed.md deleted file mode 100644 index 0209571e3..000000000 --- a/changelog/3893.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Fixed Azure TTS and STT services silently swallowing cancellation errors (invalid API key, network failures, rate limiting) instead of propagating them as `ErrorFrame`s to the pipeline. diff --git a/changelog/3896.added.md b/changelog/3896.added.md deleted file mode 100644 index 08921c004..000000000 --- a/changelog/3896.added.md +++ /dev/null @@ -1 +0,0 @@ -- Added `broadcast_interruption()` to `FrameProcessor`. This method pushes an `InterruptionFrame` both upstream and downstream directly from the calling processor, avoiding the round-trip through the pipeline task that `push_interruption_task_frame_and_wait()` required. diff --git a/changelog/3896.changed.md b/changelog/3896.changed.md deleted file mode 100644 index 3b7e4f807..000000000 --- a/changelog/3896.changed.md +++ /dev/null @@ -1 +0,0 @@ -- ⚠️ Removed `event` field and `complete()` method from `InterruptionFrame`. Removed `event` field from `InterruptionTaskFrame`. These are no longer needed since `broadcast_interruption()` does not require a round-trip completion signal. diff --git a/changelog/3896.deprecated.md b/changelog/3896.deprecated.md deleted file mode 100644 index 421e10e92..000000000 --- a/changelog/3896.deprecated.md +++ /dev/null @@ -1 +0,0 @@ -- Deprecated `push_interruption_task_frame_and_wait()` in `FrameProcessor`. Use `broadcast_interruption()` instead. The old method now delegates to `broadcast_interruption()` and logs a deprecation warning. diff --git a/changelog/3902.changed.md b/changelog/3902.changed.md deleted file mode 100644 index 95d3d592c..000000000 --- a/changelog/3902.changed.md +++ /dev/null @@ -1 +0,0 @@ -- Moved `pipecat.services.deepgram.stt_sagemaker` and `pipecat.services.deepgram.tts_sagemaker` to `pipecat.services.deepgram.sagemaker.stt` and `pipecat.services.deepgram.sagemaker.tts`. The old import paths still work but emit a `DeprecationWarning`.