From c61672194dddf2a8950e023bcfa16bd105139a83 Mon Sep 17 00:00:00 2001 From: asilvestre Date: Mon, 18 May 2026 14:40:49 +0200 Subject: [PATCH 01/12] Vonage Video Connector Transport --- env.example | 5 + examples/README.md | 14 + examples/transports/transports-vonage.py | 108 + pyproject.toml | 1 + src/pipecat/runner/run.py | 29 +- src/pipecat/runner/types.py | 15 + src/pipecat/runner/utils.py | 61 +- src/pipecat/runner/vonage.py | 52 + src/pipecat/transports/vonage/__init__.py | 0 src/pipecat/transports/vonage/client.py | 1092 ++++++ src/pipecat/transports/vonage/utils.py | 150 + .../transports/vonage/video_connector.py | 483 +++ tests/test_vonage_video_connector.py | 3101 +++++++++++++++++ 13 files changed, 5108 insertions(+), 3 deletions(-) create mode 100644 examples/transports/transports-vonage.py create mode 100644 src/pipecat/runner/vonage.py create mode 100644 src/pipecat/transports/vonage/__init__.py create mode 100644 src/pipecat/transports/vonage/client.py create mode 100644 src/pipecat/transports/vonage/utils.py create mode 100644 src/pipecat/transports/vonage/video_connector.py create mode 100644 tests/test_vonage_video_connector.py diff --git a/env.example b/env.example index 6d69cc0e9..11a7e606e 100644 --- a/env.example +++ b/env.example @@ -211,6 +211,11 @@ TWILIO_AUTH_TOKEN=... # Ultravox Realtime ULTRAVOX_API_KEY=... +# Vonage +VONAGE_APPLICATION_ID=... +VONAGE_SESSION_ID=... +VONAGE_TOKEN=... + # WhatsApp WHATSAPP_TOKEN=... WHATSAPP_WEBHOOK_VERIFICATION_TOKEN=... diff --git a/examples/README.md b/examples/README.md index 5ec86002e..8d3ff3f1f 100644 --- a/examples/README.md +++ b/examples/README.md @@ -55,6 +55,20 @@ Then, run the example with: uv run getting-started/06-voice-agent.py -t twilio -x NGROK_HOST_NAME ``` +### Vonage + +It is also possible to run the example through a Vonage session. Just provide the values for the following variables in +the `.env` file: +* VONAGE_APPLICATION_ID +* VONAGE_SESSION_ID +* VONAGE_TOKEN + +Then, run the example with: + +```bash +uv run getting-started/06-voice-agent.py -t vonage +``` + ## Directory Structure ### [`getting-started/`](./getting-started/) diff --git a/examples/transports/transports-vonage.py b/examples/transports/transports-vonage.py new file mode 100644 index 000000000..774ca1696 --- /dev/null +++ b/examples/transports/transports-vonage.py @@ -0,0 +1,108 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Example of using AWS Nova Sonic LLM service with Vonage Video Connector transport.""" + +import asyncio +import os +import sys +from collections.abc import Callable +from typing import Any + +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 PipelineTask +from pipecat.processors.aggregators.llm_context import LLMContext +from pipecat.processors.aggregators.llm_response_universal import ( + LLMContextAggregatorPair, + LLMUserAggregatorParams, +) +from pipecat.runner.vonage import configure +from pipecat.services.aws.nova_sonic.llm import AWSNovaSonicLLMService +from pipecat.transports.vonage.video_connector import ( + VonageVideoConnectorTransport, + VonageVideoConnectorTransportParams, +) + +load_dotenv(override=True) + +logger.remove(0) +logger.add(sys.stderr, level="DEBUG") + + +async def main() -> None: + """Main entry point for the nova sonic vonage video connector example.""" + (application_id, session_id, token) = await configure() + + system_instruction = ( + "You are a friendly assistant. The user and you will engage in a spoken dialog exchanging " + "the transcripts of a natural real-time conversation. Keep your responses short, generally " + "two or three sentences for chatty scenarios. " + f"{AWSNovaSonicLLMService.AWAIT_TRIGGER_ASSISTANT_RESPONSE_INSTRUCTION}" + ) + transport = VonageVideoConnectorTransport( + application_id, + session_id, + token, + VonageVideoConnectorTransportParams( + audio_in_enabled=True, + audio_out_enabled=True, + publisher_name="Bot", + ), + ) + + llm = AWSNovaSonicLLMService( + secret_access_key=os.getenv("AWS_SECRET_ACCESS_KEY", ""), + access_key_id=os.getenv("AWS_ACCESS_KEY_ID", ""), + region=os.getenv("AWS_REGION", ""), + session_token=os.getenv("AWS_SESSION_TOKEN", ""), + voice_id="tiffany", + ) + context = LLMContext( + messages=[ + {"role": "system", "content": f"{system_instruction}"}, + { + "role": "user", + "content": "Tell me a fun fact!", + }, + ], + ) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( + context, user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()) + ) + + pipeline = Pipeline( + [ + transport.input(), + user_aggregator, + llm, + transport.output(), + assistant_aggregator, + ] + ) + + task = PipelineTask(pipeline) + + # Handle client connection event + event_handler: Callable[[str], Callable[[Any], Any]] = transport.event_handler + + @event_handler("on_client_connected") + async def on_client_connected(transport: VonageVideoConnectorTransport, client: object) -> None: + logger.info(f"Client connected") + await task.queue_frames([LLMRunFrame()]) + + runner = PipelineRunner() + + await asyncio.gather(runner.run(task)) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/pyproject.toml b/pyproject.toml index 7206dae00..6edfb9cb2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -119,6 +119,7 @@ tavus = [ "pipecat-ai[daily]" ] together = [] tracing = [ "opentelemetry-sdk>=1.33.0,<2", "opentelemetry-api>=1.33.0,<2", "opentelemetry-instrumentation>=0.54b0,<1" ] ultravox = [ "pipecat-ai[websockets-base]" ] +vonage-video-connector = [ "vonage-video-connector~=0.2.3b0; python_full_version>='3.13' and python_full_version<'3.14' and platform_system=='Linux'" ] webrtc = [ "aiortc>=1.14.0,<2", "opencv-python>=4.11.0.86,<5" ] websocket = [ "pipecat-ai[websockets-base]", "fastapi>=0.115.6,<1" ] websockets-base = [ "websockets>=13.1,<16.0" ] diff --git a/src/pipecat/runner/run.py b/src/pipecat/runner/run.py index 62f3b2d51..872dca5af 100644 --- a/src/pipecat/runner/run.py +++ b/src/pipecat/runner/run.py @@ -108,8 +108,10 @@ from pipecat.runner.types import ( DailyRunnerArguments, RunnerArguments, SmallWebRTCRunnerArguments, + VonageRunnerArguments, WebSocketRunnerArguments, ) +from pipecat.runner.vonage import configure as configure_vonage try: import uvicorn @@ -983,6 +985,25 @@ async def _run_daily_direct(args: argparse.Namespace): await bot_module.bot(runner_args) +async def _run_vonage(): + """Run Vonage bot (no FastAPI server).""" + logger.info("Running Vonage transport...") + + application_id, session_id, token = await configure_vonage() + runner_args = VonageRunnerArguments( + application_id=application_id, session_id=session_id, token=token + ) + runner_args.handle_sigint = True + + # Get the bot module and run it directly + bot_module = _get_bot_module() + + print(f"Joining Vonage session: {runner_args.session_id}") + print() + + await bot_module.bot(runner_args) + + def _validate_and_clean_proxy(proxy: str) -> str: """Validate and clean proxy hostname, removing protocol if present.""" if not proxy: @@ -1062,7 +1083,7 @@ def main(parser: argparse.ArgumentParser | None = None): "-t", "--transport", type=str, - choices=["daily", "webrtc", *TELEPHONY_TRANSPORTS], + choices=["daily", "vonage", "webrtc", *TELEPHONY_TRANSPORTS], default=None, help=( "Restrict the server to a single transport and set it as the default for /start. " @@ -1169,6 +1190,12 @@ def main(parser: argparse.ArgumentParser | None = None): if args.proxy: print(f" → XML webhook: http://{args.host}:{args.port}/") print(f" → WebSocket: ws://{args.host}:{args.port}/ws") + elif args.transport == "vonage": + print() + print(f"🚀 Bot ready!") + asyncio.run(_run_vonage()) + print() + return print() RUNNER_DOWNLOADS_FOLDER = args.folder diff --git a/src/pipecat/runner/types.py b/src/pipecat/runner/types.py index b6bdd0014..6428ee507 100644 --- a/src/pipecat/runner/types.py +++ b/src/pipecat/runner/types.py @@ -99,6 +99,21 @@ class DailyRunnerArguments(RunnerArguments): token: str | None = None +@dataclass +class VonageRunnerArguments(RunnerArguments): + """Daily transport session arguments for the runner. + + Parameters: + application_id: Vonage application ID + session_id: Vonage session ID + token: Vonage Session Token + """ + + application_id: str + session_id: str + token: str + + @dataclass class WebSocketRunnerArguments(RunnerArguments): """WebSocket transport session arguments for the runner. diff --git a/src/pipecat/runner/utils.py b/src/pipecat/runner/utils.py index 19f2e6a50..2334f6da6 100644 --- a/src/pipecat/runner/utils.py +++ b/src/pipecat/runner/utils.py @@ -33,7 +33,7 @@ import json import os import re from collections.abc import Callable -from typing import Any +from typing import Any, cast from fastapi import WebSocket from loguru import logger @@ -42,9 +42,10 @@ from pipecat.runner.types import ( DailyRunnerArguments, LiveKitRunnerArguments, SmallWebRTCRunnerArguments, + VonageRunnerArguments, WebSocketRunnerArguments, ) -from pipecat.transports.base_transport import BaseTransport +from pipecat.transports.base_transport import BaseTransport, TransportParams def _detect_transport_type_from_message(message_data: dict) -> str: @@ -271,6 +272,14 @@ def get_transport_client_id(transport: BaseTransport, client: Any) -> str: except ImportError: pass + try: + from pipecat.transports.vonage.video_connector import VonageVideoConnectorTransport + + if isinstance(transport, VonageVideoConnectorTransport): + return client["streamId"] + except ImportError: + pass + logger.warning(f"Unable to get client id from unsupported transport {type(transport)}") return "" @@ -303,6 +312,24 @@ async def maybe_capture_participant_camera( except ImportError: pass + try: + from pipecat.transports.vonage.video_connector import ( + SubscribeSettings, + VonageVideoConnectorTransport, + ) + + if isinstance(transport, VonageVideoConnectorTransport): + await transport.subscribe_to_stream( + client["streamId"], + SubscribeSettings( + subscribe_to_audio=True, + subscribe_to_video=True, + preferred_framerate=framerate if framerate != 0 else None, + ), + ) + except ImportError: + pass + async def maybe_capture_participant_screen( transport: BaseTransport, client: Any, framerate: int = 0 @@ -534,6 +561,11 @@ async def create_transport( audio_out_enabled=True, # add_wav_header and serializer will be set automatically ), + "vonage": lambda: VonageVideoConnectorParams( + audio_in_enabled=True, + audio_out_enabled=True, + vad_analyzer=SileroVADAnalyzer(), + ), } transport = await create_transport(runner_args, transport_params) @@ -587,6 +619,31 @@ async def create_transport( runner_args.room_name, params=params, ) + elif isinstance(runner_args, VonageRunnerArguments): + from pipecat.transports.vonage.video_connector import ( + VonageVideoConnectorTransport, + VonageVideoConnectorTransportParams, + ) + try: + params = cast( + VonageVideoConnectorTransportParams, + _get_transport_params("vonage", transport_params), + ) + except ValueError: + webrtc_params: TransportParams = cast( + TransportParams, _get_transport_params("webrtc", transport_params) + ) + params = VonageVideoConnectorTransportParams( + **webrtc_params.model_dump(), + video_in_auto_subscribe=True, + ) + + return VonageVideoConnectorTransport( + runner_args.application_id, + runner_args.session_id, + runner_args.token, + params=params, + ) else: raise ValueError(f"Unsupported runner arguments type: {type(runner_args)}") diff --git a/src/pipecat/runner/vonage.py b/src/pipecat/runner/vonage.py new file mode 100644 index 000000000..e722c63f1 --- /dev/null +++ b/src/pipecat/runner/vonage.py @@ -0,0 +1,52 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Vonage session configuration utilities. + +This module extracts the necessary parameters to connect to a Vonage Video session. + +Required environment variables: + +- VONAGE_APPLICATION_ID - Vonage application ID +- VONAGE_SESSION_ID - Vonage session ID +- VONAGE_TOKEN - Vonage token + +Example: + from pipecat.runner.vonage import configure + + application_id, session_id, token = await configure() +""" + +import os + + +async def configure() -> tuple[str, str, str]: + """Configure Vonage application ID, session ID and token from environment. + + Returns: + Tuple containing the server application_id, session_id and token. + + Raises: + Exception: If required Vonage configuration is not provided. + """ + application_id = os.getenv("VONAGE_APPLICATION_ID") + session_id = os.getenv("VONAGE_SESSION_ID") + token = os.getenv("VONAGE_TOKEN") + + if not application_id: + raise Exception( + "No Vonage application ID specified. Use set VONAGE_APPLICATION_ID in your environment." + ) + + if not session_id: + raise Exception( + "No Vonage Session ID specified. Use set VONAGE_SESSION_ID in your environment." + ) + + if not token: + raise Exception("No Vonage token specified. Use set VONAGE_TOKEN in your environment.") + + return (application_id, session_id, token) diff --git a/src/pipecat/transports/vonage/__init__.py b/src/pipecat/transports/vonage/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/pipecat/transports/vonage/client.py b/src/pipecat/transports/vonage/client.py new file mode 100644 index 000000000..c6b703793 --- /dev/null +++ b/src/pipecat/transports/vonage/client.py @@ -0,0 +1,1092 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# +"""Vonage Video Connector client.""" + +import asyncio +import itertools +import threading +from collections.abc import Coroutine +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass, replace +from datetime import datetime, timedelta +from enum import StrEnum +from typing import Any, Awaitable, Callable, Optional, TypeVar + +import numpy as np +from loguru import logger + +from pipecat.audio.utils import create_stream_resampler +from pipecat.frames.frames import ( + InputAudioRawFrame, + OutputAudioRawFrame, + OutputImageRawFrame, + StartFrame, + UserImageRawFrame, +) +from pipecat.processors.frame_processor import FrameProcessorSetup +from pipecat.transports.base_transport import TransportParams +from pipecat.transports.vonage.utils import ( + AudioProps, + ImageFormat, + check_audio_data, + image_colorspace_conversion, + process_audio, +) +from pipecat.utils.asyncio.task_manager import BaseTaskManager + +try: + import vonage_video_connector as vonage_video + from vonage_video_connector.models import ( + AudioData, + Connection, + LoggingSettings, + Publisher, + PublisherAudioSettings, + PublisherSettings, + SessionAudioSettings, + SessionAVSettings, + SessionSettings, + SessionVideoPublisherSettings, + SubscriberSettings, + SubscriberVideoSettings, + VideoFrame, + VideoResolution, + ) + + # the following "as" imports help to make explicit the re-exporting of these types and avoid type checking warnings + # when re-importing these types from this module + from vonage_video_connector.models import ( + Session as Session, + ) + from vonage_video_connector.models import ( + Stream as Stream, + ) + from vonage_video_connector.models import ( + Subscriber as Subscriber, + ) +except ModuleNotFoundError as e: + logger.error(f"Exception: {e}") + logger.error( + f"In order to use Vonage Video Connector, you need to have the Vonage Video Connector python library installed." + ) + raise Exception(f"Missing module: {e}") + + +class VonageVideoConnectorTransportParams(TransportParams): + """Parameters for the Vonage Video Connector transport. + + Parameters: + publisher_name: Name of the publisher stream. + publisher_enable_opus_dtx: Whether to enable OPUS DTX for publisher audio. + session_enable_migration: Whether to enable session migration. + audio_in_auto_subscribe: Whether to automatically subscribe to audio streams. + video_in_auto_subscribe: Whether to automatically subscribe to video streams. + video_in_preferred_width: Preferred width for video input capture. + video_in_preferred_height: Preferred height for video input capture. + video_in_preferred_framerate: Preferred framerate for video input capture. + clear_buffers_on_interruption: Whether to clear media buffers when an interruption frame is received. + """ + + publisher_name: str = "Bot" + publisher_enable_opus_dtx: bool = False + session_enable_migration: bool = False + audio_in_auto_subscribe: bool = True + video_in_auto_subscribe: bool = False + video_connector_log_level: str = "INFO" + video_in_preferred_resolution: Optional[tuple[int, int]] = None + video_in_preferred_framerate: Optional[int] = None + clear_buffers_on_interruption: bool = True + + +@dataclass +class SubscribeSettings: + """Parameters for stream input subscription. + + Parameters: + capture_audio: Whether to subscribe to audio. + capture_video: Whether to subscribe to video. + preferred_resolution: Preferred resolution for video subscription. + preferred_framerate: Preferred framerate for video subscription. + """ + + subscribe_to_audio: bool = True + subscribe_to_video: bool = False + preferred_resolution: Optional[tuple[int, int]] = None + preferred_framerate: Optional[int] = None + + +class VonageException(Exception): + """Exception raised when a Vonage transport operation fails or encounters an error.""" + + pass + + +async def async_noop(*args: Any, **kwargs: Any) -> None: + """No operation async function.""" + pass + + +@dataclass +class VonageClientListener: + """Listener for Vonage client events. + + Parameters: + on_connected: Async callback when session is connected. + on_disconnected: Async callback when session is disconnected. + on_error: Async callback for session errors. + on_audio_in: Async callback for incoming audio data. + on_stream_received: Async callback when a stream is received. + on_stream_dropped: Async callback when a stream is dropped. + on_subscriber_connected: Async callback when a subscriber connects. + on_subscriber_disconnected: Async callback when a subscriber disconnects. + """ + + on_connected: Callable[[Session], Awaitable[None]] = async_noop + on_disconnected: Callable[[Session], Awaitable[None]] = async_noop + on_error: Callable[[Session, str, int], Awaitable[None]] = async_noop + on_audio_in: Callable[[Session, InputAudioRawFrame], Awaitable[None]] = async_noop + on_stream_received: Callable[[Session, Stream], Awaitable[None]] = async_noop + on_stream_dropped: Callable[[Session, Stream], Awaitable[None]] = async_noop + on_subscriber_connected: Callable[[Subscriber], Awaitable[None]] = async_noop + on_subscriber_disconnected: Callable[[Subscriber], Awaitable[None]] = async_noop + on_subscriber_video_in: Callable[[Subscriber, VideoFrame], Awaitable[None]] = async_noop + on_video_in: Callable[[Subscriber, UserImageRawFrame], Awaitable[None]] = async_noop + + +# the following StrEnum's don't use auto() to use the right capitalization + + +class VonageImageFormat(StrEnum): + """Enum for Vonage image formats.""" + + YUV420P = "YUV420P" + RGB24 = "RGB24" + ARGB32 = "ARGB32" + + +class PipecatImageFormat(StrEnum): + """Enum for Pipecat image formats.""" + + RGB = "RGB" + RGBA = "RGBA" + YCbCr = "YCbCr" + + +PIPECAT_TO_STANDARD_FORMAT_MAP: dict[PipecatImageFormat, ImageFormat] = { + PipecatImageFormat.YCbCr: ImageFormat.PACKED_YUV444, + PipecatImageFormat.RGB: ImageFormat.RGB, + PipecatImageFormat.RGBA: ImageFormat.RGBA, +} + +VONAGE_TO_STANDARD_FORMAT_MAP: dict[VonageImageFormat, ImageFormat] = { + VonageImageFormat.YUV420P: ImageFormat.PLANAR_YUV420, + VonageImageFormat.RGB24: ImageFormat.BGR, + VonageImageFormat.ARGB32: ImageFormat.BGRA, +} + +VONAGE_TO_PIPECAT_ANALOG_FORMAT_MAP: dict[VonageImageFormat, PipecatImageFormat] = { + VonageImageFormat.YUV420P: PipecatImageFormat.YCbCr, + VonageImageFormat.RGB24: PipecatImageFormat.RGB, + VonageImageFormat.ARGB32: PipecatImageFormat.RGBA, +} + +PIPECAT_TO_VONAGE_ANALOG_FORMAT_MAP: dict[PipecatImageFormat, VonageImageFormat] = { + v: k for k, v in VONAGE_TO_PIPECAT_ANALOG_FORMAT_MAP.items() +} + + +VIDEO_CONNECTOR_TIMEOUT: timedelta = timedelta(seconds=30) +DEFAULT_SAMPLE_RATE: int = 48000 + +AUDIO_QUEUE_MAXSIZE: int = 500 +VIDEO_QUEUE_MAXSIZE: int = 50 + +TA = TypeVar("TA", InputAudioRawFrame, OutputAudioRawFrame) +TE = TypeVar("TE", bound=StrEnum) +SimpleCoroutine = Coroutine[Any, Any, None] + +DUMMY_CONNECTION = Connection(id="", creation_time=datetime.min) + + +def _to_enum(value: Optional[str], enum_cls: type[TE]) -> Optional[TE]: + """Convert a string value to the specified StrEnum type, returning None if invalid.""" + try: + return enum_cls(value or "") + except ValueError: + return None + + +class VonageClient: + """Client for managing a Vonage Video session. + + Handles connection, publishing, subscribing, and event callbacks for a Vonage Video session. + """ + + def __init__( + self, + application_id: str, + session_id: str, + token: str, + params: VonageVideoConnectorTransportParams, + ): + """Initialize the Vonage client. + + Args: + application_id: The Vonage Video application ID. + session_id: The session ID to connect to. + token: The authentication token for the session. + params: Parameters to configure the Vonage client. + """ + self._client = vonage_video.VonageVideoClient() + self._application_id: str = application_id + self._session_id: str = session_id + self._token: str = token + self._params = params.model_copy( + # make sure we have auto-subscribe only if the respective media is enabled + update={ + "audio_in_auto_subscribe": params.audio_in_auto_subscribe + and params.audio_in_enabled, + "video_in_auto_subscribe": params.video_in_auto_subscribe + and params.video_in_enabled, + } + ) + # having these two settings separately to make them non-optional + self._audio_in_sample_rate = params.audio_in_sample_rate or DEFAULT_SAMPLE_RATE + self._audio_out_sample_rate = params.audio_out_sample_rate or DEFAULT_SAMPLE_RATE + + self._connected: bool = False + self._connection_counter: int = 0 + self._connecting_future: Optional[asyncio.Future[None]] = None + self._disconnecting_future: Optional[asyncio.Future[None]] = None + + self._listener_id_gen: itertools.count[int] = itertools.count() + self._listeners: dict[int, VonageClientListener] = {} + + self._publisher: Optional[Publisher] = None + self._session = Session(id=session_id) + + self._resampler = create_stream_resampler() + + self._task_manager: Optional[BaseTaskManager] = None + self._loop_thread_id = threading.get_ident() + self._event_queue: Optional[asyncio.Queue[SimpleCoroutine]] = None + self._event_task: Optional[asyncio.Task[None]] = None + self._audio_queue: Optional[asyncio.Queue[SimpleCoroutine]] = None + self._audio_task: Optional[asyncio.Task[None]] = None + self._video_queue: Optional[asyncio.Queue[SimpleCoroutine]] = None + self._video_task: Optional[asyncio.Task[None]] = None + + # used for blocking calls to connect and disconnect + self._executor = ThreadPoolExecutor(max_workers=1) + + self._session_streams: dict[str, Stream] = {} + self._session_subscriptions: dict[str, SubscribeSettings] = {} + out_pipecat_format = _to_enum(params.video_out_color_format or "RGB", PipecatImageFormat) + if out_pipecat_format is None: + raise VonageException( + f"Unsupported Pipecat output color format: {params.video_out_color_format}" + ) + self._out_pipecat_format: PipecatImageFormat = out_pipecat_format + self._video_out_color_format_vonage: VonageImageFormat = ( + PIPECAT_TO_VONAGE_ANALOG_FORMAT_MAP[self._out_pipecat_format] + ) + self._video_out_color_format: ImageFormat = VONAGE_TO_STANDARD_FORMAT_MAP[ + self._video_out_color_format_vonage + ] + + async def setup(self, setup: FrameProcessorSetup) -> None: + """Setup the client with task manager and event queues. + + Args: + setup: The frame processor setup configuration. + """ + if self._task_manager: + return + + self._task_manager = setup.task_manager + + # tasks from the generic event queue should allow concurrent processing as they + # may await on new events posted to the same queue + self._event_queue = asyncio.Queue() + self._event_task = self._task_manager.create_task( + self._sdk_cb_to_loop_task_handler(self._event_queue, allow_concurrent=True), + f"event_callback_task", + ) + # audio and video tasks should be processed one at a time + self._audio_queue = asyncio.Queue(maxsize=AUDIO_QUEUE_MAXSIZE) + self._audio_task = self._task_manager.create_task( + self._sdk_cb_to_loop_task_handler(self._audio_queue, allow_concurrent=False), + f"audio_callback_task", + ) + self._video_queue = asyncio.Queue(maxsize=VIDEO_QUEUE_MAXSIZE) + self._video_task = self._task_manager.create_task( + self._sdk_cb_to_loop_task_handler(self._video_queue, allow_concurrent=False), + f"video_callback_task", + ) + + async def cleanup(self) -> None: + """Cleanup the client, disconnecting if necessary.""" + if self._connected: + await self.disconnect() + + if self._event_task and self._task_manager: + await self._task_manager.cancel_task(self._event_task) + await self._event_task + self._event_task = None + if self._audio_task and self._task_manager: + await self._task_manager.cancel_task(self._audio_task) + await self._audio_task + self._audio_task = None + if self._video_task and self._task_manager: + await self._task_manager.cancel_task(self._video_task) + await self._video_task + self._video_task = None + + def add_listener(self, listener: VonageClientListener) -> int: + """Add a listener to the Vonage client. + + Args: + listener: The VonageClientListener to add. + + Returns: + The unique ID assigned to the listener. + """ + listener_id = next(self._listener_id_gen) + self._listeners[listener_id] = listener + return listener_id + + def remove_listener(self, listener_id: int) -> None: + """Remove a listener from the Vonage client. + + Args: + listener_id: The ID of the listener to remove. + """ + self._listeners.pop(listener_id, None) + + async def connect(self, frame: Optional[StartFrame] = None) -> None: + """Connect to the Vonage session. + + Args: + frame: Optional StartFrame to configure audio sample rates if not already set. + """ + logger.info(f"Connecting with session string {self._session_id}") + + if self._disconnecting_future is not None: + logger.info( + f"Waiting for disconnection to complete before connecting to {self._session_id}" + ) + await self._disconnecting_future + + if self._connected: + logger.info(f"Already connected to {self._session_id}") + self._connection_counter += 1 + return + + if self._connecting_future is not None: + logger.info(f"Already connecting to {self._session_id}") + + # if we already connecting, await for the publish ready event + await self._connecting_future + self._connection_counter += 1 + return + + # Set audio sample rates from StartFrame if params are not set + if frame: + if self._params.audio_in_sample_rate is None: + self._audio_in_sample_rate = frame.audio_in_sample_rate + if self._params.audio_out_sample_rate is None: + self._audio_out_sample_rate = frame.audio_out_sample_rate + + # this future will allow concurrent calls to connect to wait until the first connect call is done + self._connecting_future = self._get_event_loop().create_future() + + try: + await self._sdk_connect() + except Exception as exc: + logger.error(f"Error connecting to Vonage session: {exc}") + future = self._connecting_future + self._connecting_future = None + future.cancel() + raise exc + + logger.info(f"Connected to {self._session_id}") + self._connected = True + self._connection_counter += 1 + + # all concurrent calls to connect can now proceed + future = self._connecting_future + self._connecting_future = None + future.set_result(None) + + await self._notify_listeners(lambda listener: listener.on_connected(self._session)) + + async def disconnect(self) -> None: + """Disconnect from the Vonage session.""" + if self._connecting_future is not None: + logger.info( + f"Waiting for connection to complete before disconnecting from {self._session_id}" + ) + await self._connecting_future + + if not self._connected: + logger.info(f"Already disconnected from {self._session_id}") + return + + self._connection_counter -= 1 + if self._connection_counter != 0: + logger.info( + f"{self._connection_counter} connections still active for {self._session_id}" + ) + return + + self._disconnecting_future = self._get_event_loop().create_future() + + logger.info(f"Disconnecting from {self._session_id}") + + # ensure we clear up any pending SDK callback events and media buffers + if self._event_queue: + self._clear_queue(self._event_queue) + self.clear_media_buffers() + self._session_streams.clear() + self._session_subscriptions.clear() + + try: + await self._sdk_disconnect() + except Exception as exc: + logger.error(f"Error disconnecting from {self._session_id}: {exc}") + future = self._disconnecting_future + self._disconnecting_future = None + self._connection_counter += 1 + future.cancel() + raise + + logger.info(f"Disconnected from {self._session_id}") + + self._connected = False + + future = self._disconnecting_future + self._disconnecting_future = None + future.set_result(None) + + await self._notify_listeners(lambda listener: listener.on_disconnected(self._session)) + + def clear_media_buffers(self) -> None: + """Clear output media buffers in the Vonage session.""" + logger.debug(f"Clearing media buffers {self._session_id}") + if self._audio_queue: + self._clear_queue(self._audio_queue) + if self._video_queue: + self._clear_queue(self._video_queue) + self._client.clear_media_buffers() + + async def write_audio(self, audio_frame: OutputAudioRawFrame) -> bool: + """Write audio data to the Vonage session. + + Args: + audio_frame: Audio frame to write + """ + target_audio_props = AudioProps( + sample_rate=self._audio_out_sample_rate, + is_stereo=self._params.audio_out_channels == 2, + ) + proc_audio_frame = await self._process_audio_if_needed(audio_frame, target_audio_props) + + return self._client.add_audio( + AudioData( + sample_buffer=memoryview(proc_audio_frame.audio).cast("h"), + number_of_frames=proc_audio_frame.num_frames, + number_of_channels=self._params.audio_out_channels, + sample_rate=self._audio_out_sample_rate, + ) + ) + + async def subscribe_to_stream(self, stream_id: str, params: SubscribeSettings) -> None: + """Subscribe to a participant's stream. + + Args: + stream_id: The ID of the participant to subscribe to. + params: Subscription parameters for the subscription. + """ + previous_subscription: None | SubscribeSettings = self._session_subscriptions.get(stream_id) + if previous_subscription and previous_subscription == params: + logger.info(f"Already subscribed to stream {stream_id} with the same parameters") + return + + stream = self._session_streams.get(stream_id, None) or Stream( + id=stream_id, connection=DUMMY_CONNECTION + ) + if previous_subscription and previous_subscription != params: + logger.warning( + f"Already subscribed to stream {stream_id} with different parameters, " + f"re-subscribing with new parameters {params} " + f"(previous parameters were {previous_subscription})" + ) + self._client.unsubscribe(stream) + self._session_subscriptions.pop(stream_id, None) + + await self._sdk_subscribe(stream, params) + + async def write_video(self, frame: OutputImageRawFrame) -> bool: + """Write a video frame to the transport. + + Args: + frame: The output video frame to write. + """ + if not self._check_image_data(frame): + return False + + parsed_from_pipecat_format = _to_enum(frame.format, PipecatImageFormat) + if frame.format and not parsed_from_pipecat_format: + logger.error(f"Unsupported Pipecat image format: {frame.format}") + return False + from_pipecat_format: PipecatImageFormat = ( + parsed_from_pipecat_format or self._out_pipecat_format + ) + from_std_format = PIPECAT_TO_STANDARD_FORMAT_MAP[from_pipecat_format] + + processed_image = image_colorspace_conversion( + frame.image, + size=frame.size, + from_format=from_std_format, + to_format=self._video_out_color_format, + ) + if not processed_image: + logger.error( + f"Could not convert image from {from_std_format} to {self._video_out_color_format}" + ) + return False + + return self._client.add_video( + VideoFrame( + frame_buffer=memoryview(processed_image).cast("B"), + resolution=VideoResolution(width=frame.size[0], height=frame.size[1]), + format=str(self._video_out_color_format_vonage), + ), + ) + + async def _notify_listeners( + self, coroutine_func: Callable[[VonageClientListener], Awaitable[None]] + ) -> None: + """Notify all listeners with the given coroutine function. + + Args: + coroutine_func: The coroutine function to call for each listener. + """ + await asyncio.gather(*(coroutine_func(listener) for listener in self._listeners.values())) + + def _check_image_data(self, frame: OutputImageRawFrame) -> bool: + """Check the image data for validity. + + Args: + frame: The OutputImageRawFrame to check. + """ + res = True + frame_format = _to_enum(frame.format, PipecatImageFormat) + + if frame_format and frame_format != self._out_pipecat_format: + logger.error(f"Expected color format {self._out_pipecat_format}, got {frame_format}") + res = False + if ( + frame.size[0] != self._params.video_out_width + or frame.size[1] != self._params.video_out_height + ): + logger.error( + f"Expected resolution {self._params.video_out_width}x{self._params.video_out_height}, " + f"got {frame.size[0]}x{frame.size[1]}" + ) + res = False + return res + + async def _sdk_connect(self) -> None: + # this future will be set when the session is ready to publish, audio needs a special callback before + # publishing + ready_to_publish_future = self._get_event_loop().create_future() + + def on_session_error_cb(session: Session, description: str, code: int) -> None: + async def async_cb() -> None: + logger.warning(f"Session error {session.id} code={code} description={description}") + if not ready_to_publish_future.done(): + ready_to_publish_future.set_exception( + VonageException(f"Session error: {description} (code {code})") + ) + + await self._notify_listeners( + lambda listener: listener.on_error(session, description, code) + ) + + self._sdk_event_cb_to_loop(async_cb()) + + def on_session_disconnected_cb(session: Session) -> None: + async def async_cb() -> None: + unexpected_disconnection = self._disconnecting_future is None and self._connected + logger.info( + f"Session disconnected {session.id} unexpected={unexpected_disconnection}" + ) + + if not ready_to_publish_future.done(): + ready_to_publish_future.set_exception( + VonageException(f"Got disconnected while waiting for connection") + ) + + if unexpected_disconnection: + await self._notify_listeners( + lambda listener: listener.on_error(session, "unexpected disconnection", -1) + ) + + self._sdk_event_cb_to_loop(async_cb()) + + # this callback will be called when the session is ready to publish audio, video-only doesn't need it + def audio_ready_cb(session: Session) -> None: + async def async_cb() -> None: + logger.info(f"Session {session.id} ready to publish") + if not ready_to_publish_future.done(): + ready_to_publish_future.set_result(None) + + self._sdk_event_cb_to_loop(async_cb()) + + def connect_proc() -> None: + if not self._client.connect( + application_id=self._application_id, + session_id=self._session_id, + token=self._token, + session_settings=SessionSettings( + av=SessionAVSettings( + audio_publisher=SessionAudioSettings( + sample_rate=self._audio_out_sample_rate, + number_of_channels=self._params.audio_out_channels, + ), + audio_subscribers_mix=SessionAudioSettings( + sample_rate=self._audio_in_sample_rate, + number_of_channels=self._params.audio_in_channels, + ), + video_publisher=SessionVideoPublisherSettings( + resolution=VideoResolution( + width=self._params.video_out_width, + height=self._params.video_out_height, + ), + fps=self._params.video_out_framerate, + format=self._video_out_color_format_vonage, + ), + ), + enable_migration=self._params.session_enable_migration, + logging=LoggingSettings(level=self._params.video_connector_log_level), + ), + on_error_cb=on_session_error_cb, + on_connected_cb=self._on_session_connected_cb, + on_disconnected_cb=on_session_disconnected_cb, + on_stream_received_cb=self._on_stream_received_cb, + on_stream_dropped_cb=self._on_stream_dropped_cb, + on_audio_data_cb=self._on_session_audio_data_cb, + on_ready_for_audio_cb=audio_ready_cb, + ): + logger.error(f"Could not connect to {self._session_id}") + raise VonageException("Could not connect to session") + + async def async_proc() -> None: + await self._get_event_loop().run_in_executor(self._executor, connect_proc) + + # when audio publishing is enabled we need to wait for the session to be ready for audio + # however, if only video is being published at this point we don't need to wait anymore + if self._params.audio_out_enabled: + logger.info(f"Waiting for {self._session_id} to be ready to publish audio") + await ready_to_publish_future + else: + ready_to_publish_future.cancel() + + try: + await asyncio.wait_for(async_proc(), timeout=VIDEO_CONNECTOR_TIMEOUT.total_seconds()) + except asyncio.TimeoutError as exc: + logger.error(f"Timeout connecting to Vonage session {self._session_id}") + + raise exc + + async def _sdk_disconnect(self) -> None: + def disconnect_proc() -> None: + if self._publisher: + self._client.unpublish() + self._publisher = None + self._client.disconnect() + + try: + await asyncio.wait_for( + self._get_event_loop().run_in_executor(self._executor, disconnect_proc), + timeout=VIDEO_CONNECTOR_TIMEOUT.total_seconds(), + ) + except asyncio.TimeoutError: + logger.error(f"Timeout disconnecting from Vonage session {self._session_id}") + raise + + async def _sdk_subscribe(self, stream: Stream, params: SubscribeSettings) -> None: + subscribed_future = self._get_event_loop().create_future() + self._session_subscriptions[stream.id] = params + + logger.info(f"Subscribing to stream {stream.id} with params {params}") + + def on_error_cb(subscriber: Subscriber, error: str, code: int) -> None: + async def async_cb() -> None: + logger.error(f"Subscriber {subscriber.stream.id} error: {error} (code {code})") + self._session_subscriptions.pop(subscriber.stream.id, None) + if not subscribed_future.done(): + subscribed_future.set_exception( + VonageException(f"Subscriber error: {error} (code {code})") + ) + + self._sdk_event_cb_to_loop(async_cb()) + + def on_connected_cb(subscriber: Subscriber) -> None: + async def async_cb() -> None: + logger.info(f"Subscriber {subscriber.stream.id} connected") + + if not subscribed_future.done(): + subscribed_future.set_result(None) + await self._notify_listeners( + lambda listener: listener.on_subscriber_connected(subscriber) + ) + + self._sdk_event_cb_to_loop(async_cb()) + + def on_subscriber_disconnected_cb(subscriber: Subscriber) -> None: + async def async_cb() -> None: + logger.info( + f"Subscriber disconnected session={self._session_id} subscriber={subscriber.stream.id} " + ) + self._session_subscriptions.pop(subscriber.stream.id, None) + if not subscribed_future.done(): + subscribed_future.set_exception( + VonageException( + f"Subscriber {subscriber.stream.id} disconnected before connecting" + ) + ) + await self._notify_listeners( + lambda listener: listener.on_subscriber_disconnected(subscriber) + ) + + self._sdk_event_cb_to_loop(async_cb()) + + async def process() -> None: + logger.info( + f"Subscribing to stream {stream.id} audio={params.subscribe_to_audio} " + f"video={params.subscribe_to_video}" + ) + if not self._client.subscribe( + stream, + settings=SubscriberSettings( + subscribe_to_audio=params.subscribe_to_audio, + subscribe_to_video=params.subscribe_to_video, + video_settings=SubscriberVideoSettings( + preferred_resolution=( + VideoResolution( + width=params.preferred_resolution[0], + height=params.preferred_resolution[1], + ) + if params.preferred_resolution + else None + ), + preferred_framerate=params.preferred_framerate, + ), + ), + on_error_cb=on_error_cb, + on_connected_cb=on_connected_cb, + on_disconnected_cb=on_subscriber_disconnected_cb, + on_render_frame_cb=self._on_subscriber_video_data_cb, + ): + subscribed_future.cancel() + raise VonageException(f"Could not subscribe to stream {stream.id}") + + await subscribed_future + + try: + await asyncio.wait_for(process(), timeout=VIDEO_CONNECTOR_TIMEOUT.total_seconds()) + except asyncio.TimeoutError: + logger.error(f"Timeout subscribing to Vonage stream {stream.id}") + self._session_subscriptions.pop(stream.id, None) + raise + + def _sdk_publish(self) -> None: + """Publish the audio and video streams to the Vonage session.""" + if self._params.audio_out_enabled or self._params.video_out_enabled: + logger.info( + f"Publishing audio={self._params.audio_out_enabled} video={self._params.video_out_enabled} " + f"for session {self._session_id}" + ) + # TODO this could be run in the executor pool as it blocks + self._client.publish( + settings=PublisherSettings( + name=self._params.publisher_name, + audio_settings=PublisherAudioSettings( + enable_stereo_mode=self._params.audio_out_channels == 2, + enable_opus_dtx=self._params.publisher_enable_opus_dtx, + ), + has_audio=self._params.audio_out_enabled, + has_video=self._params.video_out_enabled, + ), + on_error_cb=self._on_publisher_error_cb, + on_stream_created_cb=self._on_publisher_stream_created_cb, + on_stream_destroyed_cb=self._on_publisher_stream_destroyed_cb, + ) + else: + logger.info(f"No audio or video to publish for session {self._session_id}") + + @staticmethod + def _clear_queue(queue: asyncio.Queue[SimpleCoroutine]) -> None: + """Clear all items from the given asyncio queue.""" + try: + while True: + item = queue.get_nowait() + # Close coroutines to avoid "never awaited" warnings + item.close() + queue.task_done() + except asyncio.QueueEmpty: + pass + + def _get_event_loop(self) -> asyncio.AbstractEventLoop: + """Get the event loop from the task manager.""" + if not self._task_manager: + raise Exception(f"{self}: missing task manager (pipeline not started?)") + return self._task_manager.get_event_loop() + + async def _sdk_cb_to_loop_task_handler( + self, queue: asyncio.Queue[SimpleCoroutine], allow_concurrent: bool + ) -> None: + """Read coroutines generated from SDK callbacks in the given queue executing them in the event loop.""" + # ensure we know the thread id of the event loop + self._loop_thread_id = threading.get_ident() + # if we allow concurrent tasks, process them as they come in + if allow_concurrent: + active_tasks = set() + + async def wrapped_task(coroutine: SimpleCoroutine) -> None: + try: + await coroutine + except Exception as exc: + logger.error(f"Exception in SDK callback task: {exc}") + finally: + active_tasks.discard(task) + queue.task_done() + + try: + while True: + async_task = await queue.get() + task = asyncio.create_task(wrapped_task(async_task)) + active_tasks.add(task) + except asyncio.CancelledError: + # Cancel all active tasks + for task in active_tasks: + task.cancel() + + # Wait for them to finish cancelling + if active_tasks: + await asyncio.gather(*active_tasks, return_exceptions=True) + # if we only allow one task at a time, process them sequentially + else: + while True: + try: + async_task = await queue.get() + await async_task + queue.task_done() + except asyncio.CancelledError: + break + except Exception as exc: + logger.error(f"Exception in SDK callback task: {exc}") + + def _sdk_event_cb_to_loop(self, callback: SimpleCoroutine) -> None: + """From an SDK thread queue an event coroutine to be asynchronously executed in the task manager event loop.""" + self._sdk_cb_to_loop("event", self._event_queue, callback) + + def _sdk_audio_cb_to_loop(self, callback: SimpleCoroutine) -> None: + """From an SDK thread queue an audio coroutine to be asynchronously executed in the task manager event loop.""" + self._sdk_cb_to_loop("audio", self._audio_queue, callback) + + def _sdk_video_cb_to_loop(self, callback: SimpleCoroutine) -> None: + """From an SDK thread queue a video coroutine to be asynchronously executed in the task manager event loop.""" + self._sdk_cb_to_loop("video", self._video_queue, callback) + + def _sdk_cb_to_loop( + self, + queue_type_name: str, + queue: Optional[asyncio.Queue[SimpleCoroutine]], + async_task: SimpleCoroutine, + ) -> None: + """From an SDK thread queue a coroutine to be asynchronously executed in the task manager event loop. + + If the coroutine queue is full the event will be dropped and a warning logged. + """ + if not queue: + raise Exception(f"missing {queue_type_name} queue (pipeline not started?)") + + def put_coroutine() -> None: + try: + queue.put_nowait(async_task) + except asyncio.QueueFull: + logger.warning( + f"{queue_type_name} queue is full, dropping SDK {queue_type_name} callback." + ) + async_task.close() + + if threading.get_ident() == self._loop_thread_id: + put_coroutine() + else: + self._get_event_loop().call_soon_threadsafe(put_coroutine) + + def _on_session_connected_cb(self, session: Session) -> None: + async def async_cb() -> None: + logger.info(f"Session connected {session.id}") + self._session = session + self._sdk_publish() + + self._sdk_event_cb_to_loop(async_cb()) + + def _on_publisher_error_cb(self, publisher: Publisher, description: str, code: int) -> None: + async def async_cb() -> None: + logger.warning( + f"Publisher error session={self._session_id} publisher={publisher.stream.id} " + f"code={code} description={description}" + ) + + self._sdk_event_cb_to_loop(async_cb()) + + def _on_publisher_stream_created_cb(self, publisher: Publisher) -> None: + async def async_cb() -> None: + logger.info( + f"Publisher stream created session={self._session_id} publisher={publisher.stream.id}" + ) + self._publisher = publisher + + self._sdk_event_cb_to_loop(async_cb()) + + def _on_publisher_stream_destroyed_cb(self, publisher: Publisher) -> None: + async def async_cb() -> None: + logger.info( + f"Publisher stream destroyed session={self._session_id} publisher={publisher.stream.id}" + ) + + self._sdk_event_cb_to_loop(async_cb()) + + def _on_session_audio_data_cb(self, session: Session, audio_data: AudioData) -> None: + """Callback for incoming mixed audio data for all the subscribers in the session.""" + # we need to keep a copy of the audio data as it is a memory view and it will be lost when processed async later + audio_frame = InputAudioRawFrame( + audio=audio_data.sample_buffer.tobytes(), + sample_rate=audio_data.sample_rate, + num_channels=audio_data.number_of_channels, + ) + + async def async_cb() -> None: + target_audio_props = AudioProps( + sample_rate=self._audio_in_sample_rate, + is_stereo=self._params.audio_in_channels == 2, + ) + proc_audio_frame = await self._process_audio_if_needed(audio_frame, target_audio_props) + await self._notify_listeners( + lambda listener: listener.on_audio_in(session, proc_audio_frame) + ) + + self._sdk_audio_cb_to_loop(async_cb()) + + def _on_stream_received_cb(self, session: Session, stream: Stream) -> None: + async def async_cb() -> None: + logger.info(f"Stream received session={session.id} stream={stream.id}") + self._session_streams[stream.id] = stream + + # raise the event before auto subscribing so listeners can decide what to do + await self._notify_listeners( + lambda listener: listener.on_stream_received(session, stream) + ) + + # if we have auto-subscribe enabled, subscribe to the stream if it hasn't been subscribed yet + auto_subscribe = ( + self._params.audio_in_auto_subscribe or self._params.video_in_auto_subscribe + ) + if auto_subscribe and not stream.id in self._session_subscriptions: + await self._sdk_subscribe( + stream, + SubscribeSettings( + subscribe_to_audio=self._params.audio_in_auto_subscribe, + subscribe_to_video=self._params.video_in_auto_subscribe, + preferred_resolution=(self._params.video_in_preferred_resolution), + preferred_framerate=self._params.video_in_preferred_framerate, + ), + ) + + self._sdk_event_cb_to_loop(async_cb()) + + def _on_stream_dropped_cb(self, session: Session, stream: Stream) -> None: + async def async_cb() -> None: + logger.info(f"Stream dropped session={session.id} stream={stream.id}") + if stream.id in self._session_subscriptions: + self._client.unsubscribe(stream) + self._session_subscriptions.pop(stream.id, None) + self._session_streams.pop(stream.id, None) + + await self._notify_listeners( + lambda listener: listener.on_stream_dropped(session, stream) + ) + + self._sdk_event_cb_to_loop(async_cb()) + + def _on_subscriber_video_data_cb(self, subscriber: Subscriber, frame: VideoFrame) -> None: + """Callback for incoming per stream data for all the subscribers in the session.""" + # we need to keep a copy of the audio data as it is a memory view and it will be lost when processed async later + image = frame.frame_buffer.tobytes() + + async def async_cb() -> None: + from_vonage_format = _to_enum(frame.format, VonageImageFormat) + if not from_vonage_format: + logger.error(f"Unsupported Vonage image format: {frame.format}") + return + + from_std_format = VONAGE_TO_STANDARD_FORMAT_MAP[from_vonage_format] + to_pipecat_format = VONAGE_TO_PIPECAT_ANALOG_FORMAT_MAP[from_vonage_format] + to_std_format = PIPECAT_TO_STANDARD_FORMAT_MAP[to_pipecat_format] + + processed_image = image_colorspace_conversion( + image, + size=(frame.resolution.width, frame.resolution.height), + from_format=from_std_format, + to_format=to_std_format, + ) + if not processed_image: + logger.error(f"Could not convert image from {from_std_format} to {to_std_format}") + return + + pipecat_frame = UserImageRawFrame( + user_id=subscriber.stream.id, + image=processed_image, + size=(frame.resolution.width, frame.resolution.height), + format=str(to_pipecat_format), + ) + + await self._notify_listeners( + lambda listener: listener.on_video_in(subscriber, pipecat_frame) + ) + + self._sdk_video_cb_to_loop(async_cb()) + + async def _process_audio_if_needed(self, audio_frame: TA, target_props: AudioProps) -> TA: + check_audio_data(audio_frame.audio, audio_frame.num_frames, audio_frame.num_channels) + + current_audio_props = AudioProps( + sample_rate=audio_frame.sample_rate, + is_stereo=audio_frame.num_channels == 2, + ) + if current_audio_props != target_props: + audio_np = np.frombuffer(audio_frame.audio, dtype=np.int16) + processed_audio_np = await process_audio( + self._resampler, + audio_np, + current_audio_props, + target_props, + ) + + processed_audio_frame = replace( + audio_frame, + audio=processed_audio_np.tobytes(), + sample_rate=target_props.sample_rate, + num_channels=2 if target_props.is_stereo else 1, + ) + return processed_audio_frame + else: + return audio_frame diff --git a/src/pipecat/transports/vonage/utils.py b/src/pipecat/transports/vonage/utils.py new file mode 100644 index 000000000..14d47edd3 --- /dev/null +++ b/src/pipecat/transports/vonage/utils.py @@ -0,0 +1,150 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# +"""Vonage Video Connector utils.""" + +from dataclasses import dataclass, replace +from enum import StrEnum + +import numpy as np +import numpy.typing as npt + +from pipecat.audio.resamplers.base_audio_resampler import BaseAudioResampler + + +@dataclass +class AudioProps: + """Audio properties for normalization. + + Parameters: + sample_rate: The sample rate of the audio. + is_stereo: Whether the audio is stereo (True) or mono (False). + """ + + sample_rate: int + is_stereo: bool + + +class ImageFormat(StrEnum): + """Enum for image formats.""" + + PLANAR_YUV420 = "PLANAR_YUV420" + PACKED_YUV444 = "PACKED_YUV444" + RGB = "RGB" + RGBA = "RGBA" + BGR = "BGR" + BGRA = "BGRA" + + +def check_audio_data( + buffer: bytes | memoryview, number_of_frames: int, number_of_channels: int +) -> None: + """Check the audio sample width based on buffer size, number of frames and channels.""" + if number_of_channels not in (1, 2): + raise ValueError(f"We only accept mono or stereo audio, got {number_of_channels}") + + if isinstance(buffer, memoryview): + bytes_per_sample = buffer.itemsize + else: + bytes_per_sample = len(buffer) // (number_of_frames * number_of_channels) + + if bytes_per_sample != 2: + raise ValueError(f"We only accept 16 bit PCM audio, got {bytes_per_sample * 8} bit") + + +def process_audio_channels( + audio: npt.NDArray[np.int16], current: AudioProps, target: AudioProps +) -> npt.NDArray[np.int16]: + """Normalize audio channels to the target properties.""" + if current.is_stereo != target.is_stereo: + if target.is_stereo: + audio = np.repeat(audio, 2) + else: + audio = audio.reshape(-1, 2).mean(axis=1).astype(np.int16) + + return audio + + +async def process_audio( + resampler: BaseAudioResampler, + audio: npt.NDArray[np.int16], + current: AudioProps, + target: AudioProps, +) -> npt.NDArray[np.int16]: + """Normalize audio to the target properties.""" + res_audio = audio + if current.sample_rate != target.sample_rate: + # first normalize channels to mono if needed, then resample, then normalize channels to target + res_audio = process_audio_channels(res_audio, current, replace(current, is_stereo=False)) + current = replace(current, is_stereo=False) + res_audio_bytes: bytes = await resampler.resample( + res_audio.tobytes(), current.sample_rate, target.sample_rate + ) + res_audio = np.frombuffer(res_audio_bytes, dtype=np.int16) + + res_audio = process_audio_channels(res_audio, current, target) + + return res_audio + + +def image_colorspace_conversion( + image: bytes, size: tuple[int, int], from_format: ImageFormat, to_format: ImageFormat +) -> bytes | None: + """Convert image colorspace from one format to another.""" + match (from_format, to_format): + case (fmt1, fmt2) if fmt1 == fmt2: + return image + case (ImageFormat.RGB, ImageFormat.BGR) | (ImageFormat.BGR, ImageFormat.RGB): + np_input = np.frombuffer(image, dtype=np.uint8) + np_output = np_input.reshape(size[1], size[0], 3)[:, :, ::-1] + return np_output.tobytes() + case (ImageFormat.RGBA, ImageFormat.BGRA) | (ImageFormat.BGRA, ImageFormat.RGBA): + np_input = np.frombuffer(image, dtype=np.uint8) + np_output = np_input.reshape(size[1], size[0], 4)[:, :, [2, 1, 0, 3]] + return np_output.tobytes() + case (ImageFormat.PLANAR_YUV420, ImageFormat.PACKED_YUV444): + # YUV420 (I420) has Y plane of size width*height, U and V planes of size (width/2)*(height/2) + # Packed YUV444 interleaves Y, U, V values for each pixel (YUVYUVYUV...) + width, height = size + y_plane_size = width * height + uv_plane_size_420 = (width // 2) * (height // 2) + + np_input = np.frombuffer(image, dtype=np.uint8) + y_plane = np_input[:y_plane_size].reshape(height, width) + u_plane_420 = np_input[y_plane_size : y_plane_size + uv_plane_size_420].reshape( + height // 2, width // 2 + ) + v_plane_420 = np_input[ + y_plane_size + uv_plane_size_420 : y_plane_size + 2 * uv_plane_size_420 + ].reshape(height // 2, width // 2) + + # Upsample U and V planes by repeating each pixel in 2x2 blocks + u_plane_444 = np.repeat(np.repeat(u_plane_420, 2, axis=0), 2, axis=1) + v_plane_444 = np.repeat(np.repeat(v_plane_420, 2, axis=0), 2, axis=1) + + # Interleave Y, U, V values for packed format (YUVYUVYUV...) + np_output = np.stack([y_plane, u_plane_444, v_plane_444], axis=-1) + return np_output.tobytes() + case (ImageFormat.PACKED_YUV444, ImageFormat.PLANAR_YUV420): + # Packed YUV444 has Y, U, V interleaved (YUVYUVYUV...) + # YUV420 (I420) has Y plane of size width*height, U and V planes of size (width/2)*(height/2) + width, height = size + + np_input = np.frombuffer(image, dtype=np.uint8).reshape(height, width, 3) + y_plane = np_input[:, :, 0].reshape(height, width) + u_plane_444 = np_input[:, :, 1] + v_plane_444 = np_input[:, :, 2] + + # Downsample U and V planes by taking every other pixel (2x2 -> 1 averaging) + u_plane_420 = u_plane_444[::2, ::2].reshape(height // 2, width // 2) + v_plane_420 = v_plane_444[::2, ::2].reshape(height // 2, width // 2) + + # Concatenate Y, U, V planes + np_output = np.concatenate( + [y_plane.flatten(), u_plane_420.flatten(), v_plane_420.flatten()] + ) + return np_output.tobytes() + case _: + return None diff --git a/src/pipecat/transports/vonage/video_connector.py b/src/pipecat/transports/vonage/video_connector.py new file mode 100644 index 000000000..f84fc3406 --- /dev/null +++ b/src/pipecat/transports/vonage/video_connector.py @@ -0,0 +1,483 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# +"""Vonage Video Connector transport.""" + +from typing import Optional + +from loguru import logger + +from pipecat.frames.frames import ( + CancelFrame, + EndFrame, + Frame, + InputAudioRawFrame, + InterruptionFrame, + OutputAudioRawFrame, + OutputImageRawFrame, + StartFrame, + UserImageRawFrame, +) +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 +from pipecat.transports.vonage.client import ( + Session, + Stream, + Subscriber, + VonageClient, + VonageClientListener, +) + +# the following "as" imports help to re-export these types and avoid type checking warnings +# when importing these types from the main transport module +from pipecat.transports.vonage.client import ( + SubscribeSettings as SubscribeSettings, +) +from pipecat.transports.vonage.client import ( + VonageException as VonageException, +) +from pipecat.transports.vonage.client import ( + VonageVideoConnectorTransportParams as VonageVideoConnectorTransportParams, +) + + +class VonageVideoConnectorInputTransport(BaseInputTransport): + """Input transport for Vonage, handling audio input from the Vonage session. + + Receives audio from a Vonage Video session and pushes it as input frames. + """ + + _params: VonageVideoConnectorTransportParams + + def __init__(self, client: VonageClient, params: VonageVideoConnectorTransportParams): + """Initialize the Vonage input transport. + + Args: + client: The VonageClient instance to use. + params: Transport parameters for input configuration. + """ + super().__init__(params) + self._initialized: bool = False + self._client: VonageClient = client + self._listener_id: int = -1 + self._connected: bool = False + + async def start(self, frame: StartFrame) -> None: + """Start the Vonage input transport. + + Args: + frame: The StartFrame to initiate the transport. + """ + await super().start(frame) + + if self._initialized: + return + + self._initialized = True + + if self._params.audio_in_enabled or self._params.video_in_enabled: + self._listener_id = self._client.add_listener( + VonageClientListener( + on_audio_in=self._audio_in_cb, + on_video_in=self._video_in_cb, + on_error=self._on_error_cb, + ) + ) + try: + await self._client.connect(frame) + self._connected = True + except Exception as exc: + logger.error(f"Error connecting to Vonage session: {exc}") + await self.push_error("Vonage video connector connection error", fatal=True) + return + + await self.set_transport_ready(frame) + + async def setup(self, setup: FrameProcessorSetup) -> None: + """Set up the processor with required components. + + Args: + setup: Configuration object containing setup parameters. + """ + await super().setup(setup) + await self._client.setup(setup) + + async def cleanup(self) -> None: + """Cleanup input transport.""" + await super().cleanup() # type: ignore + await self._client.cleanup() + + async def _audio_in_cb(self, _session: Session, audio: InputAudioRawFrame) -> None: + if self._connected and self._params.audio_in_enabled: + await self.push_audio_frame(audio) + + async def _video_in_cb(self, _subscriber: Subscriber, video: UserImageRawFrame) -> None: + if self._connected and self._params.video_in_enabled: + await self.push_video_frame(video) + + async def _on_error_cb(self, session: Session, description: str, code: int) -> None: + logger.error( + f"Vonage input transport error session={session.id} code={code} description={description}" + ) + if self._connected: + await self.push_error("Vonage video connector error", fatal=True) + + async def stop(self, frame: EndFrame) -> None: + """Stop the Vonage input transport. + + Args: + frame: The EndFrame to stop the transport. + """ + await super().stop(frame) + await self._stop_client() + + async def cancel(self, frame: CancelFrame) -> None: + """Cancel the Vonage input transport. + + Args: + frame: The CancelFrame to cancel the transport. + """ + await super().cancel(frame) + await self._stop_client() + + async def _stop_client(self) -> None: + if self._connected: + self._client.remove_listener(self._listener_id) + self._connected = False + try: + await self._client.disconnect() + except Exception: + pass + + async def subscribe_to_stream(self, stream_id: str, params: SubscribeSettings) -> None: + """Subscribe to a participant's stream. + + Args: + stream_id: The ID of the participant to subscribe to. + params: Subscription parameters for the subscription. + """ + await self._client.subscribe_to_stream(stream_id, params) + + +class VonageVideoConnectorOutputTransport(BaseOutputTransport): + """Output transport for Vonage, handling audio output to the Vonage session. + + Sends audio frames to a Vonage Video session as output. + """ + + _params: VonageVideoConnectorTransportParams + + def __init__(self, client: VonageClient, params: VonageVideoConnectorTransportParams): + """Initialize the Vonage output transport. + + Args: + client: The VonageClient instance to use. + params: Transport parameters for output configuration. + """ + super().__init__(params) + self._initialized: bool = False + self._client = client + self._connected: bool = False + self._listener_id: int = -1 + + async def start(self, frame: StartFrame) -> None: + """Start the Vonage output transport. + + Args: + frame: The StartFrame to initiate the transport. + """ + await super().start(frame) + + if self._initialized: + return + + self._initialized = True + + if self._params.audio_out_enabled or self._params.video_out_enabled: + self._listener_id = self._client.add_listener( + VonageClientListener(on_error=self._on_error_cb) + ) + try: + await self._client.connect(frame) + self._connected = True + except Exception as exc: + logger.error(f"Error connecting to Vonage session: {exc}") + await self.push_error("Vonage video connector connection error", fatal=True) + return + + await self.set_transport_ready(frame) + + async def setup(self, setup: FrameProcessorSetup) -> None: + """Set up the processor with required components. + + Args: + setup: Configuration object containing setup parameters. + """ + await super().setup(setup) + await self._client.setup(setup) + + async def cleanup(self) -> None: + """Cleanup output transport.""" + await super().cleanup() # type: ignore + await self._client.cleanup() + + async def process_frame(self, frame: Frame, direction: FrameDirection) -> None: + """Process a frame for the Vonage output transport. + + Args: + frame: The frame to process. + direction: The direction of frame flow in the pipeline. + """ + await super().process_frame(frame, direction) + + # if we get an interruption frame, we need to ensure the buffers inside Vonage Video Connector are cleared + if ( + self._connected + and isinstance(frame, InterruptionFrame) + and self._params.clear_buffers_on_interruption + ): + logger.info("Clearing Vonage media buffers due to interruption frame") + self._client.clear_media_buffers() + + async def write_audio_frame(self, frame: OutputAudioRawFrame) -> bool: + """Write an audio frame to the Vonage session. + + Args: + frame: The OutputAudioRawFrame to send. + """ + result = False + if self._connected and self._params.audio_out_enabled: + result = await self._client.write_audio(frame) + + return result + + async def write_video_frame(self, frame: OutputImageRawFrame) -> bool: + """Write a video frame to the transport. + + Args: + frame: The output video frame to write. + """ + result = False + if self._connected and self._params.video_out_enabled: + result = await self._client.write_video(frame) + + return result + + async def stop(self, frame: EndFrame) -> None: + """Stop the Vonage output transport. + + Args: + frame: The EndFrame to stop the transport. + """ + await super().stop(frame) + await self._stop_client() + + async def cancel(self, frame: CancelFrame) -> None: + """Cancel the Vonage output transport. + + Args: + frame: The CancelFrame to cancel the transport. + """ + await super().cancel(frame) + await self._stop_client() + + async def _stop_client(self) -> None: + if self._connected: + self._client.remove_listener(self._listener_id) + self._connected = False + try: + await self._client.disconnect() + except Exception: + pass + + async def _on_error_cb(self, session: Session, description: str, code: int) -> None: + logger.error( + f"Vonage output transport error session={session.id} code={code} description={description}" + ) + if self._connected: + await self.push_error("Vonage video connector error", fatal=True) + + +class VonageVideoConnectorTransport(BaseTransport): + """Vonage Video Connector transport implementation for Pipecat. + + Provides input and output audio transport for Vonage Video sessions, supporting event handling + for session and participant lifecycle. + + Supported features: + + - Audio input and output transport for Vonage Video sessions + - Event handler registration for session and participant events + - Publisher and subscriber management + - Configurable audio and migration parameters + """ + + _params: VonageVideoConnectorTransportParams + + def __init__( + self, + application_id: str, + session_id: str, + token: str, + params: VonageVideoConnectorTransportParams, + ): + """Initialize the Vonage Video Connector transport. + + Args: + application_id: The Vonage Video application ID. + session_id: The session ID to connect to. + token: The authentication token for the session. + params: Transport parameters for input/output configuration. + """ + super().__init__() + self._params = params + + self._client = VonageClient(application_id, session_id, token, params) + + # Register supported handlers. + self._register_event_handler("on_joined") + self._register_event_handler("on_left") + self._register_event_handler("on_error") + self._register_event_handler("on_client_connected", sync=True) + self._register_event_handler("on_client_disconnected") + self._register_event_handler("on_first_participant_joined", sync=True) + self._register_event_handler("on_participant_joined", sync=True) + self._register_event_handler("on_participant_left") + + self._client.add_listener( + VonageClientListener( + on_connected=self._on_connected, + on_disconnected=self._on_disconnected, + on_error=self._on_error, + on_stream_received=self._on_stream_received, + on_stream_dropped=self._on_stream_dropped, + on_subscriber_connected=self._on_subscriber_connected, + on_subscriber_disconnected=self._on_subscriber_disconnected, + ) + ) + + self._input: Optional[VonageVideoConnectorInputTransport] = None + self._output: Optional[VonageVideoConnectorOutputTransport] = None + self._one_stream_received: bool = False + + def input(self) -> FrameProcessor: + """Get the input transport for Vonage. + + Returns: + The VonageVideoConnectorInputTransport instance. + """ + if not self._input: + self._input = VonageVideoConnectorInputTransport(self._client, self._params) + return self._input + + def output(self) -> FrameProcessor: + """Get the output transport for Vonage. + + Returns: + The VonageVideoConnectorOutputTransport instance. + """ + if not self._output: + self._output = VonageVideoConnectorOutputTransport(self._client, self._params) + return self._output + + async def subscribe_to_stream(self, stream_id: str, params: SubscribeSettings) -> None: + """Subscribe to a participant's stream. + + Args: + stream_id: The ID of the participant to subscribe to. + params: Subscription parameters for the subscription. + """ + if self._input: + await self._input.subscribe_to_stream(stream_id, params) + + async def _on_connected(self, session: Session) -> None: + """Handle session connected event. + + Args: + session: The connected Session object. + """ + await self._call_event_handler("on_joined", {"sessionId": session.id}) + + async def _on_disconnected(self, session: Session) -> None: + """Handle session disconnected event. + + Args: + session: The disconnected Session object. + """ + await self._call_event_handler("on_left", {"sessionId": session.id}) + + async def _on_error(self, _session: Session, description: str, _code: int) -> None: + """Handle session error event. + + Args: + _session: The Session object. + description: Error description. + _code: Error code. + """ + await self._call_event_handler("on_error", description) + + async def _on_stream_received(self, session: Session, stream: Stream) -> None: + """Handle stream received event. + + Args: + session: The Session object. + stream: The received Stream object. + """ + client = { + "sessionId": session.id, + "streamId": stream.id, + "connectionData": stream.connection.data, + } + if not self._one_stream_received: + self._one_stream_received = True + await self._call_event_handler("on_first_participant_joined", client) + + await self._call_event_handler("on_participant_joined", client) + + async def _on_stream_dropped(self, session: Session, stream: Stream) -> None: + """Handle stream dropped event. + + Args: + session: The Session object. + stream: The dropped Stream object. + """ + client = { + "sessionId": session.id, + "streamId": stream.id, + "connectionData": stream.connection.data, + } + await self._call_event_handler("on_participant_left", client) + + async def _on_subscriber_connected(self, subscriber: Subscriber) -> None: + """Handle subscriber connected event. + + Args: + subscriber: The connected Subscriber object. + """ + await self._call_event_handler( + "on_client_connected", + { + "subscriberId": subscriber.stream.id, + "streamId": subscriber.stream.id, + "connectionData": subscriber.stream.connection.data, + }, + ) + + async def _on_subscriber_disconnected(self, subscriber: Subscriber) -> None: + """Handle subscriber disconnected event. + + Args: + subscriber: The disconnected Subscriber object. + """ + await self._call_event_handler( + "on_client_disconnected", + { + "subscriberId": subscriber.stream.id, + "streamId": subscriber.stream.id, + "connectionData": subscriber.stream.connection.data, + }, + ) diff --git a/tests/test_vonage_video_connector.py b/tests/test_vonage_video_connector.py new file mode 100644 index 000000000..39ca716ed --- /dev/null +++ b/tests/test_vonage_video_connector.py @@ -0,0 +1,3101 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +import asyncio +import inspect +import sys +import threading +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import Any, Awaitable, Callable, Optional +from unittest.mock import ANY, AsyncMock, MagicMock, Mock, call, patch + +import numpy as np +import pytest + +from pipecat.clocks.system_clock import SystemClock +from pipecat.frames.frames import ( + CancelFrame, + EndFrame, + InputAudioRawFrame, + InterruptionFrame, + OutputAudioRawFrame, + OutputImageRawFrame, + StartFrame, + UserImageRawFrame, +) +from pipecat.processors.frame_processor import FrameDirection, FrameProcessorSetup +from pipecat.utils.asyncio.task_manager import TaskManager, TaskManagerParams + +# Mock the vonage_video module since it's not available in test environment +vonage_video_mock = MagicMock() +vonage_video_mock.VonageVideoClient = MagicMock() +vonage_video_mock.models = MagicMock() + + +# Create mock classes that match the expected interface + + +@dataclass(eq=True, frozen=True) +class MockAudioData: + sample_buffer: memoryview + sample_rate: int + number_of_channels: int + number_of_frames: int + + +@dataclass(eq=True, frozen=True) +class MockSession: + id: str + + +@dataclass(eq=True, frozen=True) +class MockConnection: + id: str + creation_time: datetime + data: str = "" + + +DUMMY_CONNECTION = MockConnection(id="dummy", creation_time=datetime.min) + + +@dataclass(eq=True, frozen=True) +class MockStream: + id: str + connection: MockConnection + + +@dataclass(eq=True, frozen=True) +class MockPublisher: + stream: MockStream + + +@dataclass(eq=True, frozen=True) +class MockSubscriber: + stream: Optional[MockStream] = None + + +@dataclass(eq=True, frozen=True) +class MockSessionAudioSettings: + sample_rate: int = 48000 + number_of_channels: int = 2 + + +@dataclass(eq=True, frozen=True) +class MockVideoResolution: + width: int = 640 + height: int = 480 + + +@dataclass(eq=True, frozen=True) +class MockSessionVideoPublisherSettings: + resolution: MockVideoResolution + fps: int = 30 + format: str = "YUV420P" + + +@dataclass(eq=True, frozen=True) +class MockSessionAVSettings: + audio_publisher: Optional[MockSessionAudioSettings] = None + audio_subscribers_mix: Optional[MockSessionAudioSettings] = None + video_publisher: Optional[MockSessionVideoPublisherSettings] = None + + +@dataclass(eq=True, frozen=True) +class MockLoggingSettings: + level: str = "WARN" + + +@dataclass(eq=True, frozen=True) +class MockSessionSettings: + enable_migration: bool = False + av: Optional[MockSessionAVSettings] = None + logging: Optional[MockLoggingSettings] = None + + +@dataclass(eq=True, frozen=True) +class MockPublisherAudioSettings: + enable_stereo_mode: bool = True + enable_opus_dtx: bool = False + + +@dataclass(eq=True, frozen=True) +class MockPublisherSettings: + name: str + has_audio: bool + has_video: bool + audio_settings: Optional[MockPublisherAudioSettings] = None + + +@dataclass(eq=True, frozen=True) +class MockVideoFrame: + frame_buffer: memoryview + resolution: MockVideoResolution + format: str = "YUV420P" + + +@dataclass(eq=True, frozen=True) +class MockSubscriberVideoSettings: + preferred_resolution: Optional[MockVideoResolution] = None + preferred_framerate: Optional[int] = None + + +@dataclass(eq=True, frozen=True) +class MockSubscriberSettings: + subscribe_to_audio: bool = True + subscribe_to_video: bool = True + video_settings: Optional[MockSubscriberVideoSettings] = None + + +# Set up the mock module structure +vonage_video_mock.models.AudioData = MockAudioData +vonage_video_mock.models.Session = MockSession +vonage_video_mock.models.Connection = MockConnection +vonage_video_mock.models.Stream = MockStream +vonage_video_mock.models.Publisher = MockPublisher +vonage_video_mock.models.Subscriber = MockSubscriber +vonage_video_mock.models.LoggingSettings = MockLoggingSettings +vonage_video_mock.models.SessionAVSettings = MockSessionAVSettings +vonage_video_mock.models.SessionSettings = MockSessionSettings +vonage_video_mock.models.SessionAudioSettings = MockSessionAudioSettings +vonage_video_mock.models.PublisherAudioSettings = MockPublisherAudioSettings +vonage_video_mock.models.PublisherSettings = MockPublisherSettings +vonage_video_mock.models.SessionVideoPublisherSettings = MockSessionVideoPublisherSettings +vonage_video_mock.models.SubscriberSettings = MockSubscriberSettings +vonage_video_mock.models.SubscriberVideoSettings = MockSubscriberVideoSettings +vonage_video_mock.models.VideoResolution = MockVideoResolution +vonage_video_mock.models.VideoFrame = MockVideoFrame + +# Mock the module in sys.modules so imports work +sys.modules["vonage_video_connector"] = vonage_video_mock +sys.modules["vonage_video_connector.models"] = vonage_video_mock.models + +# Now we can import the transport classes since the vonage_video module is mocked +from pipecat.transports.vonage.client import ( + VonageClient, + VonageClientListener, +) +from pipecat.transports.vonage.utils import ( + AudioProps, + ImageFormat, + check_audio_data, + image_colorspace_conversion, + process_audio, + process_audio_channels, +) +from pipecat.transports.vonage.video_connector import ( + SubscribeSettings, + VonageException, + VonageVideoConnectorInputTransport, + VonageVideoConnectorOutputTransport, + VonageVideoConnectorTransport, + VonageVideoConnectorTransportParams, +) + + +@dataclass(frozen=True) +class SubscriberCallbacks: + on_error_cb: Callable[[MockSubscriber, str, int], None] + on_connected_cb: Callable[[MockSubscriber], None] + on_disconnected_cb: Callable[[MockSubscriber], None] + on_render_frame_cb: Callable[[MockSubscriber, MockVideoFrame], None] + + +@dataclass(frozen=True) +class ConnectCallbacks: + on_error_cb: Callable[[MockSession, str, int], None] + on_disconnected_cb: Callable[[MockSession], None] + on_ready_for_audio_cb: Callable[[MockSession], None] + + +class TestVonageVideoConnectorTransport: + """Test cases for Vonage Video Connector transport classes.""" + + def setup_method(self) -> None: + """Set up test fixtures.""" + self.VonageClient = VonageClient + self.VonageClientListener = VonageClientListener + self.VonageVideoConnectorInputTransport = VonageVideoConnectorInputTransport + self.VonageVideoConnectorOutputTransport = VonageVideoConnectorOutputTransport + self.VonageVideoConnectorTransport = VonageVideoConnectorTransport + self.VonageVideoConnectorTransportParams = VonageVideoConnectorTransportParams + + # Mock client instance + self.mock_client_instance = Mock() + vonage_video_mock.VonageVideoClient.return_value = self.mock_client_instance + + # Common test data + self.application_id = "test-app-id" + self.session_id = "test-session-id" + self.token = "test-token" + self._frame_processor_setup: Optional[FrameProcessorSetup] = None + self._executor = ThreadPoolExecutor(max_workers=1) + + # subscriber state + self._connect_callbacks: Optional[ConnectCallbacks] = None + self._subscriber_callbacks: dict[str, SubscriberCallbacks] = {} + + def _get_frame_processor_setup(self) -> FrameProcessorSetup: + if self._frame_processor_setup is not None: + return self._frame_processor_setup + + clock: SystemClock = SystemClock() # type: ignore[no-untyped-call] + task_manager = TaskManager() + task_manager.setup(TaskManagerParams(loop=asyncio.get_running_loop())) + self._frame_processor_setup = FrameProcessorSetup(clock=clock, task_manager=task_manager) + return self._frame_processor_setup + + async def _wait_for_condition( + self, + condition: Callable[[], bool], + timeout: timedelta = timedelta(seconds=1), + check_interval: timedelta = timedelta(milliseconds=10), + ) -> None: + """Wait for a condition to become true with timeout. + + Args: + condition: Callable that returns True when condition is met. + timeout: Maximum time to wait. + check_interval: How often to check the condition. + + Raises: + asyncio.TimeoutError: If condition is not met within timeout. + """ + start_time = asyncio.get_event_loop().time() + timeout_seconds = timeout.total_seconds() + check_interval_seconds = check_interval.total_seconds() + + while not condition(): + if asyncio.get_event_loop().time() - start_time > timeout_seconds: + raise asyncio.TimeoutError(f"Condition not met within {timeout}") + await asyncio.sleep(check_interval_seconds) + + def test_vonage_client_listener_defaults(self) -> None: + """Test VonageClientListener default values.""" + listener = self.VonageClientListener() + assert listener.on_connected is not None + assert listener.on_disconnected is not None + assert listener.on_error is not None + assert listener.on_audio_in is not None + assert listener.on_stream_received is not None + assert listener.on_stream_dropped is not None + assert listener.on_subscriber_connected is not None + assert listener.on_subscriber_disconnected is not None + + def test_vonage_transport_params_defaults(self) -> None: + """Test VonageVideoConnectorTransportParams default values.""" + params = self.VonageVideoConnectorTransportParams() + assert params.publisher_name == "Bot" + assert params.publisher_enable_opus_dtx is False + assert params.session_enable_migration is False + + def test_vonage_client_initialization(self) -> None: + """Test VonageClient initialization.""" + # Reset the mock for this specific test + vonage_video_mock.VonageVideoClient.reset_mock() + + params = self.VonageVideoConnectorTransportParams(audio_in_enabled=True) + client = self.VonageClient(self.application_id, self.session_id, self.token, params) + + assert client._application_id == self.application_id + assert client._session_id == self.session_id + assert client._token == self.token + assert client._params == params + assert client._connected is False + assert client._connection_counter == 0 + vonage_video_mock.VonageVideoClient.assert_called_once() + + # check getting the event loop before setup raises error + with pytest.raises(Exception) as exc_info: + _ = client._get_event_loop() + + assert "missing task manager" in str(exc_info.value) + + # check pushing events before setup raises error + async def mock_coro() -> None: + pass + + mock_task = mock_coro() + with pytest.raises(Exception) as exc_info: + client._sdk_event_cb_to_loop(mock_task) + + mock_task.close() + assert "missing event queue" in str(exc_info.value) + + def test_vonage_client_add_remove_listener(self) -> None: + """Test adding and removing listeners from VonageClient.""" + params = self.VonageVideoConnectorTransportParams() + client = self.VonageClient(self.application_id, self.session_id, self.token, params) + + listener = self.VonageClientListener() + listener_id = client.add_listener(listener) + + assert isinstance(listener_id, int) + assert listener_id in client._listeners + assert client._listeners[listener_id] == listener + + client.remove_listener(listener_id) + assert listener_id not in client._listeners + + def _setup_audio_ready_callback(self, client: VonageClient, call_ready_for_audio: bool) -> None: + """Helper to set up the audio ready callback.""" + + def connect_side_effect( + *_: Any, + on_error_cb: Callable[[MockSession, str, int], None], + on_disconnected_cb: Callable[[MockSession], None], + on_ready_for_audio_cb: Callable[[MockSession], None], + **__: Any, + ) -> bool: + if call_ready_for_audio: + on_ready_for_audio_cb(vonage_video_mock.models.Session(id="session")) + + self._connect_callbacks = ConnectCallbacks( + on_error_cb=on_error_cb, + on_disconnected_cb=on_disconnected_cb, + on_ready_for_audio_cb=on_ready_for_audio_cb, + ) + return True + + self.mock_client_instance.connect = MagicMock(side_effect=connect_side_effect) + + def _setup_subscriber_callbacks(self, client: VonageClient) -> None: + def subscribe_side_effect( + stream: MockStream, + on_error_cb: Callable[[MockSubscriber, str, int], None], + on_connected_cb: Callable[[MockSubscriber], None], + on_disconnected_cb: Callable[[MockSubscriber], None], + on_render_frame_cb: Callable[[MockSubscriber, MockVideoFrame], None], + **__: Any, + ) -> bool: + self._subscriber_callbacks[stream.id] = SubscriberCallbacks( + on_error_cb=on_error_cb, + on_connected_cb=on_connected_cb, + on_disconnected_cb=on_disconnected_cb, + on_render_frame_cb=on_render_frame_cb, + ) + return True + + self.mock_client_instance.subscribe = MagicMock(side_effect=subscribe_side_effect) + + async def _subscribe_n_handle_callbacks( + self, + client: VonageClient, + stream_id: str, + params: SubscribeSettings, + callback: Callable[[SubscriberCallbacks], None], + ) -> None: + task = asyncio.create_task(client.subscribe_to_stream(stream_id, params)) + await self._wait_for_condition( + lambda: stream_id in self._subscriber_callbacks, + timeout=timedelta(seconds=2), + ) + callback(self._subscriber_callbacks[stream_id]) + await task + + async def _create_client( + self, + params: Optional[VonageVideoConnectorTransportParams] = None, + setup_connect_mock: bool = True, + ) -> VonageClient: + params = params or VonageVideoConnectorTransportParams() + client = self.VonageClient(self.application_id, self.session_id, self.token, params) + if setup_connect_mock: + self._setup_audio_ready_callback(client, call_ready_for_audio=True) + + self._setup_subscriber_callbacks(client) + + await client.setup(self._get_frame_processor_setup()) + + return client + + async def _run_in_thread(self, callback: Callable[[], Any]) -> Any: + """Helper to run a coroutine in a separate thread.""" + loop = asyncio.get_event_loop() + return await loop.run_in_executor(self._executor, callback) + + async def _wait_client_async_tasks(self, client: VonageClient) -> None: + """Helper to wait for all async tasks in the client to complete.""" + # Wait for any pending tasks in the client's task manager + drain_event = asyncio.Event() + + async def set_event_when_drained() -> None: + # Wait for all queues to be joined (all tasks processed) + if client._event_queue: + await client._event_queue.join() + if client._audio_queue: + await client._audio_queue.join() + if client._video_queue: + await client._video_queue.join() + drain_event.set() + + # Schedule the drain coroutine in the event loop + asyncio.create_task(set_event_when_drained()) + await drain_event.wait() + + async def _create_output_transport( + self, params: VonageVideoConnectorTransportParams + ) -> VonageVideoConnectorOutputTransport: + client = self.VonageClient( + self.application_id, + self.session_id, + self.token, + params, + ) + transport = self.VonageVideoConnectorOutputTransport(client, params) + await transport.setup(self._get_frame_processor_setup()) + + return transport + + async def _create_input_transport( + self, params: VonageVideoConnectorTransportParams + ) -> VonageVideoConnectorInputTransport: + client = self.VonageClient( + self.application_id, + self.session_id, + self.token, + params, + ) + transport = self.VonageVideoConnectorInputTransport(client, params) + await transport.setup(self._get_frame_processor_setup()) + + return transport + + async def _create_transport( + self, params: VonageVideoConnectorTransportParams + ) -> VonageVideoConnectorTransport: + transport = VonageVideoConnectorTransport( + self.application_id, + self.session_id, + self.token, + params, + ) + await transport.input().setup(self._get_frame_processor_setup()) + await transport.output().setup(self._get_frame_processor_setup()) + + return transport + + @pytest.mark.asyncio + async def test_vonage_client_setup_n_cleanup(self) -> None: + """Test VonageClient setup and cleanup methods.""" + params = self.VonageVideoConnectorTransportParams() + client = self.VonageClient(self.application_id, self.session_id, self.token, params) + + # Before setup, task manager and queues should be None + assert client._task_manager is None + assert client._event_queue is None + assert client._event_task is None + assert client._audio_queue is None + assert client._audio_task is None + assert client._video_queue is None + assert client._video_task is None + + # Setup the client + setup = self._get_frame_processor_setup() + await client.setup(setup) + + # Mock connection + self.mock_client_instance.connect.return_value = True + client._connected = True + client._connection_counter = 1 + + # After setup, task manager and queues should be initialized + assert client._task_manager is not None + assert client._task_manager == setup.task_manager + assert client._event_queue is not None + assert client._event_task is not None + assert client._audio_queue is not None + assert client._audio_task is not None + assert client._video_queue is not None + assert client._video_task is not None + + # Test that calling setup again doesn't recreate the task manager + old_task_manager = client._task_manager + old_event_queue = client._event_queue + old_event_task = client._event_task + await client.setup(setup) + assert client._task_manager == old_task_manager + assert client._event_queue == old_event_queue + assert client._event_task == old_event_task + + # Test cleanup without being connected + await client.cleanup() + + # After cleanup, tasks should be cancelled + assert client._event_task is None + assert client._audio_task is None + assert client._video_task is None + + # Verify disconnect was called + self.mock_client_instance.disconnect.assert_called() + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "has_audio, has_video", + [ + (True, False), + (False, True), + (True, True), + (False, False), + ], + ) + async def test_vonage_client_connect_first_time(self, has_audio: bool, has_video: bool) -> None: + """Test VonageClient connect method for first connection.""" + params = self.VonageVideoConnectorTransportParams() + + # make changes to params depending on the configuration to check the right value + # goes to the right destination + params.audio_in_channels = 1 if has_video else 2 + params.audio_out_channels = 2 if has_video else 1 + params.audio_in_sample_rate = 44100 if has_video else 22050 + params.audio_out_sample_rate = 22050 if has_video else 44100 + params.session_enable_migration = has_video + params.video_out_color_format = "YCbCr" if has_audio else "RGB" + params.video_out_framerate = 30 if has_audio else 15 + params.video_out_width = 1280 if has_audio else 640 + params.video_out_height = 720 if has_audio else 480 + params.audio_in_enabled = has_audio + params.audio_out_enabled = has_audio + params.video_in_enabled = has_video + params.video_out_enabled = has_video + params.video_connector_log_level = "WARN" if has_audio else "ERROR" + + client = await self._create_client(params) + + # Mock the connect method to return True + self.mock_client_instance.connect.return_value = True + + listener = self.VonageClientListener() + listener.on_connected = AsyncMock() + # only set this callback if we have audio enabled + self._setup_audio_ready_callback(client, has_audio) + listener_id = client.add_listener(listener) + await client.connect() + + assert isinstance(listener_id, int) + self.mock_client_instance.connect.assert_called_once() + + # Verify connect was called with correct parameters + call_args = self.mock_client_instance.connect.call_args + assert call_args[1]["application_id"] == self.application_id + assert call_args[1]["session_id"] == self.session_id + assert call_args[1]["token"] == self.token + assert call_args[1]["session_settings"] == MockSessionSettings( + av=MockSessionAVSettings( + audio_publisher=MockSessionAudioSettings( + sample_rate=params.audio_out_sample_rate, + number_of_channels=params.audio_out_channels, + ), + audio_subscribers_mix=MockSessionAudioSettings( + sample_rate=params.audio_in_sample_rate, + number_of_channels=params.audio_in_channels, + ), + video_publisher=MockSessionVideoPublisherSettings( + resolution=MockVideoResolution( + width=params.video_out_width, + height=params.video_out_height, + ), + fps=params.video_out_framerate, + format=client._video_out_color_format_vonage, + ), + ), + enable_migration=params.session_enable_migration, + logging=MockLoggingSettings(level=params.video_connector_log_level), + ) + assert self._connect_callbacks is not None + assert call_args[1]["on_audio_data_cb"] == client._on_session_audio_data_cb + assert call_args[1]["on_error_cb"] == self._connect_callbacks.on_error_cb + assert call_args[1]["on_connected_cb"] == client._on_session_connected_cb + assert call_args[1]["on_disconnected_cb"] == self._connect_callbacks.on_disconnected_cb + assert ( + call_args[1]["on_ready_for_audio_cb"] == self._connect_callbacks.on_ready_for_audio_cb + ) + assert call_args[1]["on_stream_received_cb"] == client._on_stream_received_cb + assert call_args[1]["on_stream_dropped_cb"] == client._on_stream_dropped_cb + + listener.on_connected.assert_called_once() + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "has_audio, has_video", + [ + (True, False), + (False, True), + (True, True), + (False, False), + ], + ) + async def test_vonage_client_publish_after_connect( + self, has_audio: bool, has_video: bool + ) -> None: + """Test VonageClient publishes after being connected method for first connection.""" + params = self.VonageVideoConnectorTransportParams() + + # make changes to params depending on the configuration to check the right value + # goes to the right destination + params.audio_in_enabled = has_audio + params.audio_out_enabled = has_audio + params.video_in_enabled = has_video + params.video_out_enabled = has_video + params.audio_out_channels = 2 if has_video else 1 + params.publisher_enable_opus_dtx = not has_video + params.publisher_name = "test-audio" if has_audio else "test-video" + + client = await self._create_client(params) + await client.connect() + + self.mock_client_instance.connect.assert_called_once() + + # trigger the _on_session_connected_cb to simulate being connected + await self._run_in_thread( + lambda: client._on_session_connected_cb(vonage_video_mock.models.Session(id="session")) + ) + await self._wait_client_async_tasks(client) + + # if no audio and no video, publish should not be called + if not has_audio and not has_video: + self.mock_client_instance.publish.assert_not_called() + return + + # Verify publish was called with correct parameters + self.mock_client_instance.publish.assert_called_once() + call_args = self.mock_client_instance.publish.call_args + assert call_args[1]["settings"] == MockPublisherSettings( + name=params.publisher_name, + audio_settings=MockPublisherAudioSettings( + enable_stereo_mode=params.audio_out_channels == 2, + enable_opus_dtx=params.publisher_enable_opus_dtx, + ), + has_audio=has_audio, + has_video=has_video, + ) + assert call_args[1]["on_error_cb"] == client._on_publisher_error_cb + assert call_args[1]["on_stream_created_cb"] == client._on_publisher_stream_created_cb + assert call_args[1]["on_stream_destroyed_cb"] == client._on_publisher_stream_destroyed_cb + + @pytest.mark.asyncio + async def test_vonage_client_connect_already_connected(self) -> None: + """Test VonageClient connect when already connected.""" + params = self.VonageVideoConnectorTransportParams(audio_in_enabled=True) + client = await self._create_client(params) + + # add some listeners, tests multiple listeners are notified, test no notifiaction after removal too + listener1 = self.VonageClientListener() + listener1.on_connected = AsyncMock() + client.add_listener(listener1) + listener2 = self.VonageClientListener() + listener2.on_connected = AsyncMock() + client.add_listener(listener2) + listener3 = self.VonageClientListener() + listener3.on_connected = AsyncMock() + listener_id3 = client.add_listener(listener3) + client.remove_listener(listener_id3) + + # First connection, connection is performed + await client.connect() + self.mock_client_instance.connect.assert_called_once() + listener1.on_connected.assert_called_once() + listener2.on_connected.assert_called_once() + + # Second connection, should not trigger a new connect call or raised any events + await client.connect() + self.mock_client_instance.connect.assert_called_once() + listener1.on_connected.assert_called_once() + listener2.on_connected.assert_called_once() + + # the removed listener should not have received any events + listener3.on_connected.assert_not_called() + + @pytest.mark.asyncio + async def test_vonage_client_connect_while_disconnecting(self) -> None: + """Test VonageClient waits for disconnect to complete before connecting.""" + params = self.VonageVideoConnectorTransportParams(audio_in_enabled=True) + client = await self._create_client(params) + + self.mock_client_instance.disconnect = MagicMock() + + # Simulate disconnect in progress + disconnect_future = asyncio.get_running_loop().create_future() + client._disconnecting_future = disconnect_future + + # Start connect task - it should block waiting for disconnect + connect_task = asyncio.create_task(client.connect()) + + # Give control to the event loop to let connect task start + await asyncio.sleep(0.2) + + self.mock_client_instance.connect.assert_not_called() + + # Resolve the disconnect future to unblock connect + disconnect_future.set_result(None) + + # Wait for connect to complete + await connect_task + + self.mock_client_instance.connect.assert_called_once() + + # Verify client state + assert client._connected is True + assert client._connection_counter == 1 + + @pytest.mark.asyncio + async def test_vonage_client_timeout_while_connecting(self) -> None: + """Test VonageClient handles timeout during connection.""" + params = self.VonageVideoConnectorTransportParams(audio_in_enabled=True) + client = await self._create_client(params, setup_connect_mock=False) + + # Create an event that will block but can be interrupted + stop_event = threading.Event() + + # Mock the SDK connect method to block until interrupted + def connect_blocks_forever(*args: Any, **kwargs: Any) -> bool: + stop_event.wait(timeout=10) # Wait max 10 seconds but can be interrupted + return True + + self.mock_client_instance.connect.side_effect = connect_blocks_forever + + try: + # Patch the timeout to be very short for fast test execution + with patch( + "pipecat.transports.vonage.client.VIDEO_CONNECTOR_TIMEOUT", + timedelta(seconds=0.1), + ): + # Attempt to connect, should timeout + with pytest.raises(asyncio.TimeoutError): + await client.connect() + + # Verify client state after timeout + assert client._connected is False + assert client._connection_counter == 0 + assert client._connecting_future is None + finally: + # Stop the blocking thread + stop_event.set() + + @pytest.mark.asyncio + async def test_vonage_client_concurrent_connects(self) -> None: + """Test VonageClient concurrent connects.""" + params = self.VonageVideoConnectorTransportParams(audio_in_enabled=True) + client = await self._create_client(params) + + # Mock the connect method to return True and store the callback + connecting_future: asyncio.Future[Callable[[Any], None]] = ( + asyncio.get_running_loop().create_future() + ) + + def connect_side_effect( + *_: Any, on_ready_for_audio_cb: Optional[Callable[[Any], None]] = None, **__: Any + ) -> bool: + assert on_ready_for_audio_cb is not None + connecting_future.set_result(on_ready_for_audio_cb) + return True + + self.mock_client_instance.connect = MagicMock(side_effect=connect_side_effect) + + # create a listener + listener = self.VonageClientListener() + listener.on_connected = AsyncMock() + client.add_listener(listener) + + # send two parallel connect calls and let them get stuck awaiting + connect1_task = asyncio.create_task(client.connect()) + connect2_task = asyncio.create_task(client.connect()) + + audio_ready_cb = await connecting_future + + # Now both connects are waiting on the same promise, we can set it to complete + audio_ready_cb(vonage_video_mock.models.Session(id="session")) + + # await for the connections to now complete + await asyncio.gather(connect1_task, connect2_task) + + # SDK connect should only be called once + self.mock_client_instance.connect.assert_called_once() + listener.on_connected.assert_called_once() + + @pytest.mark.asyncio + async def test_vonage_client_connect_failure(self) -> None: + """Test VonageClient connect method when connection fails.""" + client = await self._create_client(setup_connect_mock=False) + + # Mock the connect method to return False + self.mock_client_instance.connect.return_value = False + + with pytest.raises(Exception) as exc_info: + await client.connect() + + assert "Could not connect to session" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_vonage_client_disconnect_before_connecting(self) -> None: + """Test VonageClient disconnect method before connecting.""" + client = await self._create_client() + + listener = self.VonageClientListener() + listener.on_disconnected = AsyncMock() + client.add_listener(listener) + + await client.disconnect() + + self.mock_client_instance.disconnect.assert_not_called() + listener.on_disconnected.assert_not_called() + + @pytest.mark.asyncio + async def test_vonage_client_disconnect(self) -> None: + """Test VonageClient disconnect method.""" + client = await self._create_client() + + # create a listener + listener = self.VonageClientListener() + listener.on_disconnected = AsyncMock() + client.add_listener(listener) + + # send two parallel connect calls and let them get stuck awaiting + connect_promise1 = client.connect() + connect_promise2 = client.connect() + + # await for the connections to now complete + await asyncio.gather(connect_promise1, connect_promise2) + + # Add some items to the queues before disconnect + assert client._event_queue is not None + assert client._audio_queue is not None + assert client._video_queue is not None + + async def mock_event_task() -> None: + pass + + async def mock_audio_task() -> None: + pass + + async def mock_video_task() -> None: + pass + + await client._event_queue.put(mock_event_task()) + await client._audio_queue.put(mock_audio_task()) + await client._video_queue.put(mock_video_task()) + + # Verify queues have items + assert client._event_queue.qsize() == 1 + assert client._audio_queue.qsize() == 1 + assert client._video_queue.qsize() == 1 + + # Mock the client's clear_media_buffers method + self.mock_client_instance.clear_media_buffers = MagicMock() + + # send the first disconnect call, we should still be connected + await client.disconnect() + self.mock_client_instance.disconnect.assert_not_called() + self.mock_client_instance.clear_media_buffers.assert_not_called() + listener.on_disconnected.assert_not_called() + + # Queues should still have items since we didn't actually disconnect + assert client._event_queue.qsize() == 1 + assert client._audio_queue.qsize() == 1 + assert client._video_queue.qsize() == 1 + + # check the second disconnect now disconnects for real + await client.disconnect() + self.mock_client_instance.disconnect.assert_called_once() + self.mock_client_instance.clear_media_buffers.assert_called_once() + listener.on_disconnected.assert_called_once() + + # Verify queues are now empty after disconnect + assert client._event_queue.qsize() == 0 + assert client._audio_queue.qsize() == 0 + assert client._video_queue.qsize() == 0 + + # an extra disconnect should not do anything + await client.disconnect() + self.mock_client_instance.disconnect.assert_called_once() + self.mock_client_instance.clear_media_buffers.assert_called_once() + listener.on_disconnected.assert_called_once() + + @pytest.mark.asyncio + async def test_vonage_client_disconnect_while_connecting(self) -> None: + """Test VonageClient waits for connect to complete before disconnecting.""" + params = self.VonageVideoConnectorTransportParams(audio_in_enabled=True) + client = await self._create_client(params) + + client._connected = True + client._connection_counter = 1 + self.mock_client_instance.connect = MagicMock() + + # Simulate connect in progress + connect_future = asyncio.get_running_loop().create_future() + client._connecting_future = connect_future + + # Start disconnect task - it should block waiting for disconnect + disconnect_task = asyncio.create_task(client.disconnect()) + + # Give control to the event loop to let disconnect task start + await asyncio.sleep(0.2) + + self.mock_client_instance.disconnect.assert_not_called() + + # Resolve the disconnect future to unblock connect + connect_future.set_result(None) + + # Wait for connect to complete + await disconnect_task + + self.mock_client_instance.disconnect.assert_called_once() + + # Verify client state + assert client._connected is False + assert client._connection_counter == 0 + + @pytest.mark.asyncio + async def test_vonage_client_timeout_while_disconnecting(self) -> None: + """Test VonageClient handles timeout during disconnection.""" + params = self.VonageVideoConnectorTransportParams(audio_in_enabled=True) + client = await self._create_client(params, setup_connect_mock=False) + + await client.connect() + assert client._connection_counter == 1 + + # Create an event that will block but can be interrupted + stop_event = threading.Event() + + # Mock the SDK disconnect method to block until interrupted + def disconnect_blocks_forever(*args: Any, **kwargs: Any) -> bool: + stop_event.wait(timeout=10) # Wait max 10 seconds but can be interrupted + return True + + self.mock_client_instance.disconnect.side_effect = disconnect_blocks_forever + try: + # Patch the timeout to be very short for fast test execution + with patch( + "pipecat.transports.vonage.client.VIDEO_CONNECTOR_TIMEOUT", + timedelta(seconds=0.1), + ): + # Attempt to connect, should timeout + with pytest.raises(asyncio.TimeoutError): + await client.disconnect() + + # Verify client state after timeout + assert client._connected is True + assert client._connection_counter == 1 + assert client._disconnecting_future is None + finally: + # Stop the blocking thread + stop_event.set() + + @pytest.mark.asyncio + async def test_vonage_client_clear_media_buffers(self) -> None: + """Test VonageClient clear_media_buffers method.""" + params = self.VonageVideoConnectorTransportParams( + audio_out_channels=2, audio_out_sample_rate=48000 + ) + client = await self._create_client(params) + + # Add some items to the audio and video queues + assert client._audio_queue is not None + assert client._video_queue is not None + + # Create mock coroutines to add to queues + async def mock_audio_task() -> None: + pass + + async def mock_video_task() -> None: + pass + + # Put some items in the queues + await client._audio_queue.put(mock_audio_task()) + await client._audio_queue.put(mock_audio_task()) + await client._video_queue.put(mock_video_task()) + + # Verify queues have items + assert client._audio_queue.qsize() == 2 + assert client._video_queue.qsize() == 1 + + # Mock the client's clear_media_buffers method + self.mock_client_instance.clear_media_buffers = MagicMock() + + # Clear the buffers + client.clear_media_buffers() + + # Verify queues are now empty + assert client._audio_queue.qsize() == 0 + assert client._video_queue.qsize() == 0 + + # Verify the SDK client's clear_media_buffers was called + self.mock_client_instance.clear_media_buffers.assert_called_once() + + @pytest.mark.asyncio + @patch("pipecat.transports.vonage.client.VIDEO_QUEUE_MAXSIZE", 1) + async def test_vonage_client_sdk_cb_to_loop_full_queue(self) -> None: + """Test VonageClient SDK callback to loop filling up the queue.""" + params = self.VonageVideoConnectorTransportParams() + client = await self._create_client(params) + + # Ensure the loop thread ID is set + assert client._video_queue is not None + assert client._loop_thread_id == threading.get_ident() + + # Create a mock coroutine to queue + async def mock_task() -> None: + pass + + # Fill queue to max size + for _ in range(client._video_queue.maxsize): + await client._video_queue.put(mock_task()) + + # Queue should be full + assert client._video_queue.qsize() == client._video_queue.maxsize + # This should log an error and drop the event + async_task = mock_task() + client._sdk_cb_to_loop("test_event", client._video_queue, async_task) + + # Queue should still be full (no new item added) + assert client._video_queue.qsize() == client._video_queue.maxsize + # check the coroutine was closed and hence dropped + assert inspect.getcoroutinestate(async_task) == "CORO_CLOSED" + + # Clean up the coroutine + task = await client._video_queue.get() + task.close() + client._video_queue.task_done() + + @pytest.mark.asyncio + @patch("pipecat.transports.vonage.client.create_stream_resampler") + async def test_vonage_client_get_audio_with_resampling(self, mock_resampler: MagicMock) -> None: + """Test VonageClient get_audio method.""" + # Return resampled stereo data + resampled_data = b"\x07\x06\x05\x04\x03\x02\x01\x00" + mock_resampler_instance = Mock() + mock_resampler_instance.resample = AsyncMock(return_value=resampled_data) + mock_resampler.return_value = mock_resampler_instance + + params = self.VonageVideoConnectorTransportParams( + audio_in_channels=1, + audio_in_sample_rate=48000, + audio_in_enabled=True, + ) + client = await self._create_client(params) + listener = self.VonageClientListener() + on_audio_in_mock = AsyncMock() + listener.on_audio_in = on_audio_in_mock + client.add_listener(listener) + + await client.connect() + + mock_audio_data = vonage_video_mock.models.AudioData( + sample_buffer=memoryview(b"\x00\x01\x02\x03\x04\x05\x06\x07"), + number_of_frames=4, + number_of_channels=1, + sample_rate=16000, + ) + + session = vonage_video_mock.models.Session(id="test_session") + client._on_session_audio_data_cb(session, mock_audio_data) + await self._wait_for_condition(lambda: on_audio_in_mock.call_count > 0) + + listener.on_audio_in.assert_called_once_with(session, ANY) + frame = listener.on_audio_in.call_args[0][1] + assert frame.audio == resampled_data + assert frame.num_frames == 4 + assert frame.sample_rate == 48000 + assert frame.num_channels == 1 + + @pytest.mark.asyncio + async def test_vonage_client_get_video(self) -> None: + """Test VonageClient get video.""" + pass + + @pytest.mark.asyncio + async def test_vonage_client_write_audio(self) -> None: + """Test VonageClient write_audio method.""" + params = self.VonageVideoConnectorTransportParams( + audio_out_channels=2, audio_out_sample_rate=48000 + ) + client = await self._create_client(params) + + # Create mock audio data + audio_data = OutputAudioRawFrame( + audio=b"\x00\x01\x02\x03\x04\x05\x06\x07", + sample_rate=48000, + num_channels=2, + ) # 4 frames of 2-channel 16-bit audio + + await client.write_audio(audio_data) + + self.mock_client_instance.add_audio.assert_called_once() + call_args = self.mock_client_instance.add_audio.call_args[0][0] + assert call_args.sample_buffer.tobytes() == audio_data.audio + assert call_args.number_of_frames == 2 # 8 bytes / (2 channels * 2 bytes) + assert call_args.number_of_channels == 2 + assert call_args.sample_rate == 48000 + + @pytest.mark.asyncio + @patch("pipecat.transports.vonage.client.create_stream_resampler") + async def test_vonage_client_write_audio_with_resampling( + self, mock_resampler: MagicMock + ) -> None: + """Test VonageClient write_audio method.""" + # Return resampled stereo data + resampled_data = b"\x07\x06\x05\x04\x03\x02\x01\x00" + mock_resampler_instance = Mock() + mock_resampler_instance.resample = AsyncMock(return_value=resampled_data) + mock_resampler.return_value = mock_resampler_instance + + params = self.VonageVideoConnectorTransportParams( + audio_out_channels=1, audio_out_sample_rate=16000 + ) + client = await self._create_client(params) + + # Create mock audio data + audio_data = OutputAudioRawFrame( + audio=b"\x00\x01\x02\x03\x04\x05\x06\x07", + sample_rate=48000, + num_channels=1, + ) # 4 frames of 1-channel 16-bit audio + + await client.write_audio(audio_data) + + self.mock_client_instance.add_audio.assert_called_once() + call_args = self.mock_client_instance.add_audio.call_args[0][0] + assert call_args.sample_buffer.tobytes() == resampled_data + assert call_args.number_of_frames == 4 # 8 bytes / (1 channel * 2 bytes) + assert call_args.number_of_channels == 1 + assert call_args.sample_rate == 16000 + + @pytest.mark.asyncio + async def test_vonage_client_write_video(self) -> None: + """Test VonageClient write_video method.""" + params = self.VonageVideoConnectorTransportParams( + video_out_width=640, + video_out_height=480, + video_out_color_format="RGB", + ) + client = await self._create_client(params) + + # Create a test RGB image (640x480, 3 channels) + width, height = 640, 480 + # Create RGB data: simple gradient pattern + rgb_image = np.zeros((height, width, 3), dtype=np.uint8) + rgb_image[:, :, 0] = 100 # R channel + rgb_image[:, :, 1] = 150 # G channel + rgb_image[:, :, 2] = 200 # B channel + + rgb_bytes = rgb_image.tobytes() + + # Create OutputImageRawFrame + frame = OutputImageRawFrame(image=rgb_bytes, size=(width, height), format="RGB") + + # Mock the add_video method + self.mock_client_instance.add_video = MagicMock(return_value=True) + + result = await client.write_video(frame) + + # Verify add_video was called + assert result is True + self.mock_client_instance.add_video.assert_called_once() + + # Get the VideoFrame argument + call_args = self.mock_client_instance.add_video.call_args[0][0] + + # Verify the resolution + assert call_args.resolution.width == width + assert call_args.resolution.height == height + + # Verify the format + assert call_args.format == "RGB24" + + # Verify BGR conversion happened correctly + # Convert back from the buffer to verify + bgr_buffer = bytes(call_args.frame_buffer) + bgr_image = np.frombuffer(bgr_buffer, dtype=np.uint8).reshape(height, width, 3) + + # Check that RGB was converted to BGR (channels swapped) + assert bgr_image[0, 0, 0] == 200 # B channel (was R=200 in RGB) + assert bgr_image[0, 0, 1] == 150 # G channel (unchanged) + assert bgr_image[0, 0, 2] == 100 # R channel (was B=100 in RGB) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "has_audio, has_video", + [ + (True, False), + (False, True), + (True, True), + ], + ) + async def test_vonage_client_subscribe_to_stream( + self, has_audio: bool, has_video: bool + ) -> None: + """Test VonageClient subscribe_to_stream with a stream that exists in session.""" + params = self.VonageVideoConnectorTransportParams(audio_in_enabled=True) + client = await self._create_client(params) + + listener = self.VonageClientListener() + client.add_listener(listener) + on_subscriber_connected_mock = AsyncMock() + listener.on_subscriber_connected = on_subscriber_connected_mock + on_subscriber_disconnected_mock = AsyncMock() + listener.on_subscriber_disconnected = on_subscriber_disconnected_mock + + await client.connect() + + # Add a stream to the session + stream = vonage_video_mock.models.Stream(id="test_stream", connection=DUMMY_CONNECTION) + client._session_streams["test_stream"] = stream + + # Setup subscriber callbacks + self._setup_subscriber_callbacks(client) + + # Subscribe with audio and video + subscribe_params = SubscribeSettings( + subscribe_to_audio=has_audio, + subscribe_to_video=has_video, + preferred_resolution=(640, 480) if has_video else None, + preferred_framerate=30 if has_video else None, + ) + + subscriber = vonage_video_mock.models.Subscriber(stream=stream) + await self._subscribe_n_handle_callbacks( + client, + "test_stream", + subscribe_params, + lambda callbacks: callbacks.on_connected_cb(subscriber), + ) + on_subscriber_connected_mock.assert_called_once_with(subscriber) + + # Verify subscribe was called with correct parameters + self.mock_client_instance.subscribe.assert_called_once() + call_args = self.mock_client_instance.subscribe.call_args + + expected_settings = MockSubscriberSettings( + subscribe_to_audio=has_audio, + subscribe_to_video=has_video, + video_settings=MockSubscriberVideoSettings( + preferred_resolution=MockVideoResolution( + width=subscribe_params.preferred_resolution[0], + height=subscribe_params.preferred_resolution[1], + ) + if subscribe_params.preferred_resolution + else None, + preferred_framerate=subscribe_params.preferred_framerate, + ), + ) + + assert call_args[0][0] == stream + assert call_args[1]["settings"] == expected_settings + + # Verify subscription was stored + assert "test_stream" in client._session_subscriptions + assert client._session_subscriptions["test_stream"] == subscribe_params + + # check we can get a disconnect event from the subscriber + self._subscriber_callbacks["test_stream"].on_disconnected_cb(subscriber) + await self._wait_for_condition(lambda: on_subscriber_disconnected_mock.call_count > 0) + on_subscriber_disconnected_mock.assert_called_once_with(subscriber) + + @pytest.mark.asyncio + async def test_vonage_client_subscribe_to_stream_same_and_different_params(self) -> None: + """Test VonageClient subscribe_to_stream when subscribing multiple times with same and different parameters.""" + params = self.VonageVideoConnectorTransportParams(audio_in_enabled=True) + client = await self._create_client(params) + + listener = self.VonageClientListener() + client.add_listener(listener) + on_subscriber_connected_mock = AsyncMock() + listener.on_subscriber_connected = on_subscriber_connected_mock + + await client.connect() + + # Add a stream to the session + stream = vonage_video_mock.models.Stream(id="test_stream", connection=DUMMY_CONNECTION) + client._session_streams["test_stream"] = stream + + # Setup subscriber callbacks + self._setup_subscriber_callbacks(client) + + # First subscription with specific parameters + subscribe_params_1 = SubscribeSettings( + subscribe_to_audio=True, + subscribe_to_video=True, + preferred_resolution=(640, 480), + preferred_framerate=30, + ) + + subscriber = vonage_video_mock.models.Subscriber(stream=stream) + await self._subscribe_n_handle_callbacks( + client, + "test_stream", + subscribe_params_1, + lambda callbacks: callbacks.on_connected_cb(subscriber), + ) + on_subscriber_connected_mock.assert_called_once_with(subscriber) + + # Verify subscribe was called once + assert self.mock_client_instance.subscribe.call_count == 1 + assert "test_stream" in client._session_subscriptions + assert client._session_subscriptions["test_stream"] == subscribe_params_1 + + # Subscribe again with SAME parameters - should do nothing + await client.subscribe_to_stream("test_stream", subscribe_params_1) + + # Verify subscribe was still only called once (no new subscription) + assert self.mock_client_instance.subscribe.call_count == 1 + # Verify unsubscribe was NOT called + self.mock_client_instance.unsubscribe.assert_not_called() + # Subscription should remain unchanged + assert client._session_subscriptions["test_stream"] == subscribe_params_1 + + # Subscribe again with DIFFERENT parameters - should unsubscribe and resubscribe + subscribe_params_2 = SubscribeSettings( + subscribe_to_audio=False, + subscribe_to_video=True, + preferred_resolution=(1280, 720), + preferred_framerate=60, + ) + + # Track the current subscribe call count before resubscribing + initial_subscribe_count = self.mock_client_instance.subscribe.call_count + + # Start the resubscription + resubscribe_task = asyncio.create_task( + client.subscribe_to_stream("test_stream", subscribe_params_2) + ) + + # Wait for the new subscribe call to be made + await self._wait_for_condition( + lambda: self.mock_client_instance.subscribe.call_count > initial_subscribe_count, + timeout=timedelta(seconds=2), + ) + + # Now trigger the connected callback with the new subscription callbacks + self._subscriber_callbacks["test_stream"].on_connected_cb(subscriber) + await resubscribe_task + + # Verify unsubscribe was called once (to remove old subscription) + self.mock_client_instance.unsubscribe.assert_called_once_with(stream) + # Verify subscribe was called a second time (for new subscription) + assert self.mock_client_instance.subscribe.call_count == 2 + # Verify new subscription parameters are stored + assert client._session_subscriptions["test_stream"] == subscribe_params_2 + + # Verify the second subscribe call had the new parameters + second_call_args = self.mock_client_instance.subscribe.call_args + expected_settings = MockSubscriberSettings( + subscribe_to_audio=False, + subscribe_to_video=True, + video_settings=MockSubscriberVideoSettings( + preferred_resolution=MockVideoResolution(width=1280, height=720), + preferred_framerate=60, + ), + ) + assert second_call_args[0][0] == stream + assert second_call_args[1]["settings"] == expected_settings + + @pytest.mark.asyncio + async def test_vonage_client_subscribe_to_stream_timeout(self) -> None: + """Test VonageClient subscribe_to_stream when SDK subscribe times out.""" + params = self.VonageVideoConnectorTransportParams(audio_in_enabled=True) + client = await self._create_client(params) + + await client.connect() + + stream = vonage_video_mock.models.Stream(id="fail_stream", connection=DUMMY_CONNECTION) + client._session_streams["fail_stream"] = stream + + self._setup_subscriber_callbacks(client) + + subscribe_params = SubscribeSettings(subscribe_to_audio=True, subscribe_to_video=False) + + # Patch the timeout to be very short for fast test execution + # the call never gets on_connected_cb or any other callback, it will timeout + with patch( + "pipecat.transports.vonage.client.VIDEO_CONNECTOR_TIMEOUT", + timedelta(seconds=0.1), + ): + with pytest.raises(asyncio.TimeoutError): + await client.subscribe_to_stream("fail_stream", subscribe_params) + + @pytest.mark.asyncio + async def test_vonage_client_subscribe_to_stream_fails(self) -> None: + """Test VonageClient subscribe_to_stream when SDK subscribe fails.""" + params = self.VonageVideoConnectorTransportParams(audio_in_enabled=True) + client = await self._create_client(params) + + await client.connect() + + stream = vonage_video_mock.models.Stream(id="fail_stream", connection=DUMMY_CONNECTION) + client._session_streams["fail_stream"] = stream + + self._setup_subscriber_callbacks(client) + self.mock_client_instance.subscribe.side_effect = lambda *_, **__: False + + subscribe_params = SubscribeSettings(subscribe_to_audio=True, subscribe_to_video=False) + with pytest.raises(VonageException) as exc_info: + await client.subscribe_to_stream("fail_stream", subscribe_params) + + assert "Could not subscribe to stream" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_vonage_client_subscribe_to_stream_subscriber_error(self) -> None: + """Test VonageClient subscribe_to_stream when subscriber reports an error.""" + params = self.VonageVideoConnectorTransportParams(audio_in_enabled=True) + client = await self._create_client(params) + + await client.connect() + + stream = vonage_video_mock.models.Stream(id="error_stream", connection=DUMMY_CONNECTION) + client._session_streams["error_stream"] = stream + + self._setup_subscriber_callbacks(client) + + subscribe_params = SubscribeSettings(subscribe_to_audio=True, subscribe_to_video=False) + + # Subscription should raise an exception + with pytest.raises(VonageException) as exc_info: + subscriber = vonage_video_mock.models.Subscriber(stream=stream) + await self._subscribe_n_handle_callbacks( + client, + "error_stream", + subscribe_params, + lambda callbacks: callbacks.on_error_cb(subscriber, "Connection failed", 1500), + ) + + assert "Subscriber error" in str(exc_info.value) + assert "Connection failed" in str(exc_info.value) + assert "(code 1500)" in str(exc_info.value) + + # Verify subscription was removed + assert "error_stream" not in client._session_subscriptions + + @pytest.mark.asyncio + async def test_vonage_client_subscribe_to_stream_subscriber_disconnected_before_connected( + self, + ) -> None: + """Test VonageClient subscribe_to_stream when subscriber disconnects before connecting.""" + params = self.VonageVideoConnectorTransportParams(audio_in_enabled=True) + client = await self._create_client(params) + + await client.connect() + + stream = vonage_video_mock.models.Stream(id="dc_stream", connection=DUMMY_CONNECTION) + client._session_streams["dc_stream"] = stream + + self._setup_subscriber_callbacks(client) + + # Add listener to track disconnection + listener = self.VonageClientListener() + on_subscriber_disconnected_mock = AsyncMock() + listener.on_subscriber_disconnected = on_subscriber_disconnected_mock + client.add_listener(listener) + + subscribe_params = SubscribeSettings(subscribe_to_audio=True, subscribe_to_video=False) + + # Subscription should raise an exception + subscriber = vonage_video_mock.models.Subscriber(stream=stream) + with pytest.raises(VonageException) as exc_info: + await self._subscribe_n_handle_callbacks( + client, + "dc_stream", + subscribe_params, + lambda callbacks: callbacks.on_disconnected_cb(subscriber), + ) + + assert "disconnected before connecting" in str(exc_info.value) + + # Verify subscription was removed + assert "dc_stream" not in client._session_subscriptions + + # Verify listener was called + listener.on_subscriber_disconnected.assert_awaited_once_with(subscriber) + + @pytest.mark.asyncio + async def test_vonage_client_on_stream_received_triggers_listeners(self) -> None: + """Test that _on_stream_received_cb triggers on_stream_received listener callbacks.""" + params = self.VonageVideoConnectorTransportParams( + audio_in_enabled=True, audio_in_auto_subscribe=False + ) + client = await self._create_client(params) + + # Add multiple listeners + listener1 = self.VonageClientListener() + on_stream_received_mock1 = AsyncMock() + listener1.on_stream_received = on_stream_received_mock1 + client.add_listener(listener1) + + listener2 = self.VonageClientListener() + on_stream_received_mock2 = AsyncMock() + listener2.on_stream_received = on_stream_received_mock2 + client.add_listener(listener2) + + await client.connect() + + session = vonage_video_mock.models.Session(id="test_session") + stream = vonage_video_mock.models.Stream(id="test_stream", connection=DUMMY_CONNECTION) + + # Trigger the callback + client._on_stream_received_cb(session, stream) + + # Wait for async processing + await self._wait_for_condition( + lambda: on_stream_received_mock1.await_count > 0 + and on_stream_received_mock2.await_count > 0 + ) + + # Verify both listeners were called + on_stream_received_mock1.assert_awaited_once_with(session, stream) + on_stream_received_mock2.assert_awaited_once_with(session, stream) + + # Verify stream was added to session streams + assert "test_stream" in client._session_streams + assert client._session_streams["test_stream"] == stream + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "auto_audio, auto_video", + [ + (True, False), + (False, True), + (True, True), + (False, False), + ], + ) + async def test_vonage_client_on_stream_received_auto_subscribe( + self, auto_audio: bool, auto_video: bool + ) -> None: + """Test that _on_stream_received_cb auto-subscribes when auto_subscribe is enabled.""" + params = self.VonageVideoConnectorTransportParams( + audio_in_enabled=True, + video_in_enabled=True, + audio_in_auto_subscribe=auto_audio, + video_in_auto_subscribe=auto_video, + video_in_preferred_resolution=(640, 480) if auto_video else None, + video_in_preferred_framerate=25 if auto_video else None, + ) + client = await self._create_client(params) + + await client.connect() + + self._setup_subscriber_callbacks(client) + + session = vonage_video_mock.models.Session(id="test_session") + stream = vonage_video_mock.models.Stream(id="auto_sub_stream", connection=DUMMY_CONNECTION) + + # Trigger the callback + client._on_stream_received_cb(session, stream) + await self._wait_for_condition(lambda: stream.id in client._session_streams) + + if not auto_audio and not auto_video: + await self._wait_client_async_tasks(client) + # No auto-subscribe should happen + await self._wait_client_async_tasks(client) + self.mock_client_instance.subscribe.assert_not_called() + return + + # Wait for auto-subscribe to happen + await self._wait_for_condition(lambda: "auto_sub_stream" in self._subscriber_callbacks) + + # Verify subscribe was called + self.mock_client_instance.subscribe.assert_called_once() + call_args = self.mock_client_instance.subscribe.call_args + + # Verify subscription settings + expected_settings = MockSubscriberSettings( + subscribe_to_audio=auto_audio, + subscribe_to_video=auto_video, + video_settings=MockSubscriberVideoSettings( + preferred_resolution=MockVideoResolution(width=640, height=480) + if auto_video + else None, + preferred_framerate=25 if auto_video else None, + ), + ) + assert call_args[0][0] == stream + assert call_args[1]["settings"] == expected_settings + + # Verify subscription was stored + assert "auto_sub_stream" in client._session_subscriptions + assert client._session_subscriptions["auto_sub_stream"].subscribe_to_audio == auto_audio + assert client._session_subscriptions["auto_sub_stream"].subscribe_to_video == auto_video + + self._subscriber_callbacks["auto_sub_stream"].on_connected_cb(MockSubscriber(stream=stream)) + await self._wait_client_async_tasks(client) + + @pytest.mark.asyncio + async def test_vonage_client_on_stream_received_skips_existing_subscription(self) -> None: + """Test that _on_stream_received_cb does not auto-subscribe if stream is already subscribed.""" + params = self.VonageVideoConnectorTransportParams( + audio_in_enabled=True, audio_in_auto_subscribe=True + ) + client = await self._create_client(params) + + listener = self.VonageClientListener() + on_stream_received_mock = AsyncMock() + listener.on_stream_received = on_stream_received_mock + client.add_listener(listener) + + await client.connect() + + self._setup_subscriber_callbacks(client) + + session = vonage_video_mock.models.Session(id="test_session") + stream = vonage_video_mock.models.Stream(id="existing_stream", connection=DUMMY_CONNECTION) + + # Manually add an existing subscription + client._session_subscriptions["existing_stream"] = SubscribeSettings( + subscribe_to_audio=True, subscribe_to_video=False + ) + + # Trigger the callback + client._on_stream_received_cb(session, stream) + + # Wait for listener to be called + await self._wait_for_condition(lambda: on_stream_received_mock.await_count > 0) + on_stream_received_mock.assert_awaited_once_with(session, stream) + + # Wait to ensure no subscription happens + await self._wait_client_async_tasks(client) + + # Verify subscribe was NOT called (because subscription already exists) + self.mock_client_instance.subscribe.assert_not_called() + + @pytest.mark.asyncio + async def test_vonage_client_events(self) -> None: + """Test VonageClient events""" + params = self.VonageVideoConnectorTransportParams( + audio_in_enabled=True, + audio_out_enabled=True, + audio_in_sample_rate=48000, + audio_in_channels=2, + ) + client = await self._create_client(params) + + # Mock the connect method to return True + self.mock_client_instance.connect.return_value = True + self._setup_audio_ready_callback(client, call_ready_for_audio=True) + self._setup_subscriber_callbacks(client) + + # create a listener + listener = self.VonageClientListener() + on_error_mock = AsyncMock() + listener.on_error = on_error_mock + on_audio_in_mock = AsyncMock() + listener.on_audio_in = on_audio_in_mock + on_stream_received_mock = AsyncMock() + listener.on_stream_received = on_stream_received_mock + on_stream_dropped_mock = AsyncMock() + listener.on_stream_dropped = on_stream_dropped_mock + on_subscriber_connected_mock = AsyncMock() + listener.on_subscriber_connected = on_subscriber_connected_mock + on_subscriber_disconnected_mock = AsyncMock() + listener.on_subscriber_disconnected = on_subscriber_disconnected_mock + + client.add_listener(listener) + + # connect + await client.connect() + + assert self._connect_callbacks is not None + + # Test _on_session_error_cb triggers on_error + session = vonage_video_mock.models.Session(id="test_session") + error_description = "Test error description" + error_code = 500 + + self._connect_callbacks.on_error_cb(session, error_description, error_code) + await self._wait_for_condition(lambda: on_error_mock.await_count > 0) + + listener.on_error.assert_called_once_with(session, error_description, error_code) + listener.on_error.reset_mock() + + # Test _on_session_audio_data_cb triggers on_audio_in + audio_buffer = np.array([100, 200, 300, 400], dtype=np.int16) + mock_audio_data = vonage_video_mock.models.AudioData( + sample_buffer=memoryview(audio_buffer), + number_of_frames=2, + number_of_channels=2, + sample_rate=48000, + ) + + client._on_session_audio_data_cb(session, mock_audio_data) + await self._wait_for_condition(lambda: on_audio_in_mock.await_count > 0) + + listener.on_audio_in.assert_awaited_once_with(session, ANY) + frame = listener.on_audio_in.call_args[0][1] + assert frame.audio == audio_buffer.tobytes() + assert frame.sample_rate == 48000 + assert frame.num_channels == 2 + listener.on_audio_in.reset_mock() + # Test _on_stream_received_cb triggers on_stream_received + stream = vonage_video_mock.models.Stream(id="test_stream", connection=DUMMY_CONNECTION) + + client._on_stream_received_cb(session, stream) + await self._wait_for_condition(lambda: on_stream_received_mock.await_count > 0) + listener.on_stream_received.assert_awaited_once_with(session, stream) + + await self._wait_for_condition(lambda: stream.id in self._subscriber_callbacks) + + assert stream.id in self._subscriber_callbacks + callbacks = self._subscriber_callbacks[stream.id] + self.mock_client_instance.subscribe.assert_called_once_with( + stream, + settings=ANY, + on_error_cb=callbacks.on_error_cb, + on_connected_cb=callbacks.on_connected_cb, + on_disconnected_cb=callbacks.on_disconnected_cb, + on_render_frame_cb=client._on_subscriber_video_data_cb, + ) + listener.on_stream_received.reset_mock() + + subscriber = vonage_video_mock.models.Subscriber(stream=stream) + callbacks.on_connected_cb(subscriber) + await self._wait_for_condition(lambda: on_subscriber_connected_mock.await_count > 0) + listener.on_subscriber_connected.assert_awaited_once() + listener.on_subscriber_connected.reset_mock() + + # Test _on_subscriber_disconnected_cb triggers on_subscriber_disconnected + callbacks.on_disconnected_cb(subscriber) + await self._wait_for_condition(lambda: on_subscriber_disconnected_mock.await_count > 0) + + listener.on_subscriber_disconnected.assert_awaited_once_with(subscriber) + listener.on_subscriber_disconnected.reset_mock() + + # Test _on_stream_dropped_cb triggers on_stream_dropped + self.mock_client_instance.unsubscribe = MagicMock() + + client._on_stream_dropped_cb(session, stream) + await self._wait_for_condition(lambda: on_stream_dropped_mock.await_count > 0) + + listener.on_stream_dropped.assert_awaited_once_with(session, stream) + self.mock_client_instance.unsubscribe.assert_not_called() + listener.on_stream_dropped.reset_mock() + + # Test _on_subscriber_connected_cb triggers on_subscriber_connected + subscriber_stream = vonage_video_mock.models.Stream( + id="subscriber_stream", connection=DUMMY_CONNECTION + ) + subscriber = vonage_video_mock.models.Subscriber(stream=subscriber_stream) + + # Test error callbacks are logged but don't trigger listener events + # (these are internal error callbacks, not session errors) + publisher_stream = vonage_video_mock.models.Stream( + id="publisher_stream", connection=DUMMY_CONNECTION + ) + publisher = vonage_video_mock.models.Publisher(stream=publisher_stream) + + # These should not raise exceptions + client._on_publisher_error_cb(publisher, "publisher error", 400) + callbacks.on_error_cb(subscriber, "subscriber error", 401) + + await self._wait_client_async_tasks(client) + + @pytest.mark.asyncio + async def test_vonage_client_on_subscriber_video_data_cb_rgb_format(self) -> None: + """Test _on_subscriber_video_data_cb with RGB format video frames.""" + from pipecat.frames.frames import UserImageRawFrame + + params = self.VonageVideoConnectorTransportParams( + video_in_enabled=True, + video_in_auto_subscribe=False, + ) + client = await self._create_client(params) + + # Add listener to capture video frames + listener = self.VonageClientListener() + on_video_in_mock = AsyncMock() + listener.on_video_in = on_video_in_mock + client.add_listener(listener) + + await client.connect() + + # Create a test RGB video frame (4x4, 3 channels) + width, height = 4, 4 + rgb_image = np.zeros((height, width, 3), dtype=np.uint8) + rgb_image[:, :, 0] = 100 # R channel + rgb_image[:, :, 1] = 150 # G channel + rgb_image[:, :, 2] = 200 # B channel + + rgb_bytes = rgb_image.tobytes() + + # Create mock video frame with RGB24 format (which Vonage uses for RGB) + mock_video_frame = vonage_video_mock.models.VideoFrame( + frame_buffer=memoryview(rgb_bytes), + resolution=MockVideoResolution(width=width, height=height), + format="RGB24", + ) + + # Create mock subscriber + stream = vonage_video_mock.models.Stream(id="video_stream", connection=DUMMY_CONNECTION) + subscriber = vonage_video_mock.models.Subscriber(stream=stream) + + # Trigger the callback + client._on_subscriber_video_data_cb(subscriber, mock_video_frame) + + # Wait for async processing + await self._wait_for_condition(lambda: on_video_in_mock.await_count > 0) + + # Verify listener was called + on_video_in_mock.assert_awaited_once() + call_args = on_video_in_mock.call_args[0] + assert call_args[0] == subscriber + + # Get the processed frame + processed_frame: UserImageRawFrame = call_args[1] + assert processed_frame.user_id == "video_stream" + assert processed_frame.size == (width, height) + assert processed_frame.format == "RGB" + + # Verify BGR to RGB conversion happened + processed_image = np.frombuffer(processed_frame.image, dtype=np.uint8).reshape( + height, width, 3 + ) + assert processed_image[0, 0, 0] == 200 # R channel (was B in BGR) + assert processed_image[0, 0, 1] == 150 # G channel (unchanged) + assert processed_image[0, 0, 2] == 100 # B channel (was R in BGR) + + @pytest.mark.asyncio + async def test_vonage_input_transport_initialization(self) -> None: + """Test VonageVideoConnectorInputTransport initialization.""" + params = self.VonageVideoConnectorTransportParams() + client = self.VonageClient(self.application_id, self.session_id, self.token, params) + + transport_params = self.VonageVideoConnectorTransportParams(audio_in_enabled=True) + transport = self.VonageVideoConnectorInputTransport(client, transport_params) + + assert transport._client == client + assert transport._initialized is False + + @pytest.mark.asyncio + async def test_vonage_input_transport_start(self) -> None: + """Test VonageVideoConnectorInputTransport start method.""" + params = self.VonageVideoConnectorTransportParams(audio_in_enabled=True) + client = self.VonageClient(self.application_id, self.session_id, self.token, params) + transport = self.VonageVideoConnectorInputTransport(client, params) + + # Mock the client connect method + with ( + patch.object(client, "connect", AsyncMock(return_value=1)) as client_connect_mock, + patch.object(transport, "set_transport_ready", AsyncMock()) as set_transport_ready_mock, + ): + start_frame = StartFrame() + await transport.start(start_frame) + + assert transport._initialized is True + assert transport._connected is True + client_connect_mock.assert_called_once() + set_transport_ready_mock.assert_called_once_with(start_frame) + + @pytest.mark.asyncio + async def test_vonage_input_transport_stop(self) -> None: + """Test VonageVideoConnectorInputTransport stop method.""" + params = self.VonageVideoConnectorTransportParams(audio_in_enabled=True) + client = self.VonageClient(self.application_id, self.session_id, self.token, params) + transport = self.VonageVideoConnectorInputTransport(client, params) + transport._listener_id = 1 + transport._connected = True + + with ( + patch.object(client, "disconnect", AsyncMock()) as client_disconnect_mock, + patch.object(client, "remove_listener", MagicMock()) as remove_listener_mock, + ): + end_frame = EndFrame() + await transport.stop(end_frame) + + client_disconnect_mock.assert_called_once() + remove_listener_mock.assert_called_once_with(1) + assert not transport._connected + + @pytest.mark.asyncio + async def test_vonage_input_transport_cancel(self) -> None: + """Test VonageVideoConnectorInputTransport cancel method.""" + params = self.VonageVideoConnectorTransportParams(audio_in_enabled=True) + client = self.VonageClient(self.application_id, self.session_id, self.token, params) + + transport = self.VonageVideoConnectorInputTransport(client, params) + transport._listener_id = 1 + transport._connected = True + + # Mock the client disconnect method + with ( + patch.object(client, "disconnect", AsyncMock()) as client_disconnect_mock, + patch.object(client, "remove_listener", MagicMock()) as remove_listener_mock, + ): + cancel_frame = CancelFrame() + await transport.cancel(cancel_frame) + + client_disconnect_mock.assert_called_once() + remove_listener_mock.assert_called_once_with(1) + assert not transport._connected + + @pytest.mark.asyncio + async def test_vonage_output_transport_initialization(self) -> None: + """Test VonageVideoConnectorOutputTransport initialization.""" + params = self.VonageVideoConnectorTransportParams() + client = self.VonageClient(self.application_id, self.session_id, self.token, params) + + transport_params = self.VonageVideoConnectorTransportParams(audio_out_enabled=True) + transport = self.VonageVideoConnectorOutputTransport(client, transport_params) + + assert transport._client == client + assert transport._initialized is False + + @pytest.mark.asyncio + async def test_vonage_output_transport_start(self) -> None: + """Test VonageVideoConnectorOutputTransport start method.""" + params = self.VonageVideoConnectorTransportParams() + client = self.VonageClient(self.application_id, self.session_id, self.token, params) + + transport_params = self.VonageVideoConnectorTransportParams(audio_out_enabled=True) + transport = self.VonageVideoConnectorOutputTransport(client, transport_params) + + with ( + patch.object(client, "connect", AsyncMock(return_value=1)) as client_connect_mock, + patch.object(transport, "set_transport_ready", AsyncMock()) as set_transport_ready_mock, + ): + start_frame = StartFrame() + await transport.start(start_frame) + + assert transport._initialized is True + client_connect_mock.assert_called_once() + set_transport_ready_mock.assert_called_once_with(start_frame) + + @pytest.mark.asyncio + async def test_vonage_output_transport_write_audio_frame(self) -> None: + """Test VonageVideoConnectorOutputTransport write_audio_frame method.""" + + params = self.VonageVideoConnectorTransportParams( + audio_out_sample_rate=48000, audio_out_channels=2, audio_out_enabled=True + ) + client = self.VonageClient(self.application_id, self.session_id, self.token, params) + + with patch.object(client, "write_audio", AsyncMock()) as client_write_audio_mock: + transport_params = self.VonageVideoConnectorTransportParams(audio_out_enabled=True) + transport = self.VonageVideoConnectorOutputTransport(client, transport_params) + transport._connected = True + + # Create a mock audio frame + audio_frame = OutputAudioRawFrame( + audio=b"\x00\x01\x02\x03", sample_rate=16000, num_channels=1 + ) + + await transport.write_audio_frame(audio_frame) + + # Verify audio was written to client + client_write_audio_mock.assert_called_once_with(audio_frame) + + @pytest.mark.asyncio + async def test_vonage_output_transport_write_video_frame_not_connected(self) -> None: + """Test VonageVideoConnectorOutputTransport write_video_frame method.""" + transport = await self._create_output_transport( + params=self.VonageVideoConnectorTransportParams(video_out_enabled=True) + ) + client = transport._client + + # Create a test video frame + width, height = 640, 480 + rgb_image = np.zeros((height, width, 3), dtype=np.uint8) + rgb_image[:, :, 0] = 100 + rgb_image[:, :, 1] = 150 + rgb_image[:, :, 2] = 200 + + video_frame = OutputImageRawFrame( + image=rgb_image.tobytes(), size=(width, height), format="RGB" + ) + + with patch.object(client, "write_video", AsyncMock(return_value=True)) as write_video_mock: + await transport.stop(EndFrame()) + result = await transport.write_video_frame(video_frame) + + # Should return False when not connected + assert result is False + write_video_mock.assert_not_called() + + @pytest.mark.asyncio + async def test_vonage_output_transport_write_video_frame_connected(self) -> None: + """Test VonageVideoConnectorOutputTransport write_video_frame method when connected.""" + transport = await self._create_output_transport( + params=self.VonageVideoConnectorTransportParams( + video_out_enabled=True, + video_out_width=640, + video_out_height=480, + video_out_color_format="RGB", + ) + ) + client = transport._client + + # Create a test video frame + width, height = 640, 480 + rgb_image = np.zeros((height, width, 3), dtype=np.uint8) + rgb_image[:, :, 0] = 100 + rgb_image[:, :, 1] = 150 + rgb_image[:, :, 2] = 200 + + video_frame = OutputImageRawFrame( + image=rgb_image.tobytes(), size=(width, height), format="RGB" + ) + + with patch.object(client, "write_video", AsyncMock(return_value=True)) as write_video_mock: + transport._connected = True + result = await transport.write_video_frame(video_frame) + + # Should return True and call write_video when connected + assert result is True + write_video_mock.assert_called_once_with(video_frame) + + @pytest.mark.asyncio + async def test_vonage_output_transport_write_video_frame_invalid_size(self) -> None: + """Test VonageVideoConnectorOutputTransport write_video_frame with invalid frame size.""" + transport = await self._create_output_transport( + params=self.VonageVideoConnectorTransportParams( + video_out_enabled=True, + video_out_width=640, + video_out_height=480, + video_out_color_format="RGB", + ) + ) + + # Create a video frame with incorrect size + width, height = 320, 240 # Different from expected 640x480 + rgb_image = np.zeros((height, width, 3), dtype=np.uint8) + + video_frame = OutputImageRawFrame( + image=rgb_image.tobytes(), size=(width, height), format="RGB" + ) + + transport._connected = True + result = await transport.write_video_frame(video_frame) + + # Should return False for invalid size + assert result is False + + @pytest.mark.asyncio + async def test_vonage_output_transport_write_video_frame_invalid_format(self) -> None: + """Test VonageVideoConnectorOutputTransport write_video_frame with invalid color format.""" + transport = await self._create_output_transport( + params=self.VonageVideoConnectorTransportParams( + video_out_enabled=True, + video_out_width=640, + video_out_height=480, + video_out_color_format="YCbCr", + ) + ) + + # Create a video frame with incorrect size + width, height = 320, 240 # Different from expected 640x480 + rgb_image = np.zeros((height, width, 3), dtype=np.uint8) + + video_frame = OutputImageRawFrame( + image=rgb_image.tobytes(), size=(width, height), format="RGB" + ) + + transport._connected = True + result = await transport.write_video_frame(video_frame) + + # Should return False for invalid size + assert result is False + + @pytest.mark.asyncio + async def test_vonage_output_transport_process_frame_with_interruption(self) -> None: + """Test VonageVideoConnectorOutputTransport process_frame method with InterruptionFrame.""" + transport = await self._create_output_transport( + params=self.VonageVideoConnectorTransportParams(audio_out_enabled=True) + ) + client = transport._client + + with ( + patch.object(client, "clear_media_buffers") as clear_buffers_mock, + patch.object(client, "connect", AsyncMock()), + ): + await transport.start(StartFrame()) + interruption_frame = InterruptionFrame() + await transport.process_frame(interruption_frame, FrameDirection.DOWNSTREAM) + + # Verify clear_media_buffers was called + clear_buffers_mock.assert_called_once() + + @pytest.mark.asyncio + async def test_vonage_output_transport_process_frame_without_interruption(self) -> None: + """Test VonageVideoConnectorOutputTransport process_frame method with non-interruption frame.""" + transport = await self._create_output_transport( + params=self.VonageVideoConnectorTransportParams(audio_out_enabled=True) + ) + client = transport._client + + with patch.object(client, "clear_media_buffers") as clear_buffers_mock: + audio_frame = OutputAudioRawFrame( + audio=b"\x00\x01\x02\x03", sample_rate=16000, num_channels=1 + ) + await transport.process_frame(audio_frame, FrameDirection.DOWNSTREAM) + + # Verify clear_media_buffers was NOT called for non-interruption frames + clear_buffers_mock.assert_not_called() + + @pytest.mark.asyncio + async def test_vonage_output_transport_process_frame_when_not_connected(self) -> None: + """Test VonageVideoConnectorOutputTransport process_frame method when not connected.""" + transport = await self._create_output_transport( + params=self.VonageVideoConnectorTransportParams(audio_out_enabled=True) + ) + await transport.stop(EndFrame()) # Ensure transport is not connected + client = transport._client + + with patch.object(client, "clear_media_buffers") as clear_buffers_mock: + interruption_frame = InterruptionFrame() + await transport.process_frame(interruption_frame, FrameDirection.DOWNSTREAM) + + # Verify clear_media_buffers was NOT called when not connected + clear_buffers_mock.assert_not_called() + + @pytest.mark.asyncio + async def test_vonage_output_transport_interruption_with_clear_buffers_disabled(self) -> None: + """Test VonageVideoConnectorOutputTransport with clear_buffers_on_interruption=False.""" + transport = await self._create_output_transport( + params=self.VonageVideoConnectorTransportParams( + audio_out_enabled=True, clear_buffers_on_interruption=False + ) + ) + client = transport._client + + with ( + patch.object(client, "clear_media_buffers") as clear_buffers_mock, + patch.object(client, "connect", AsyncMock()), + ): + await transport.start(StartFrame()) + interruption_frame = InterruptionFrame() + await transport.process_frame(interruption_frame, FrameDirection.DOWNSTREAM) + + # Verify clear_media_buffers was NOT called when clear_buffers_on_interruption is False + clear_buffers_mock.assert_not_called() + + @pytest.mark.asyncio + async def test_vonage_output_transport_interruption_with_clear_buffers_enabled(self) -> None: + """Test VonageVideoConnectorOutputTransport with clear_buffers_on_interruption=True (default).""" + transport = await self._create_output_transport( + params=self.VonageVideoConnectorTransportParams( + audio_out_enabled=True, clear_buffers_on_interruption=True + ) + ) + client = transport._client + + with ( + patch.object(client, "clear_media_buffers") as clear_buffers_mock, + patch.object(client, "connect", AsyncMock()), + ): + await transport.start(StartFrame()) + interruption_frame = InterruptionFrame() + await transport.process_frame(interruption_frame, FrameDirection.DOWNSTREAM) + + # Verify clear_media_buffers was called when clear_buffers_on_interruption is True + clear_buffers_mock.assert_called_once() + + @pytest.mark.asyncio + @pytest.mark.parametrize("transport_type", ["input", "output"]) + async def test_vonage_transport_sets_audio_sample_rates_from_start_frame( + self, transport_type: str + ) -> None: + """Test transport sets audio sample rates from StartFrame when params are None.""" + # Create params with None sample rates + params = self.VonageVideoConnectorTransportParams( + audio_in_enabled=(transport_type == "input"), + audio_out_enabled=(transport_type == "output"), + audio_in_sample_rate=None, + audio_out_sample_rate=None, + ) + transport: VonageVideoConnectorInputTransport | VonageVideoConnectorOutputTransport + if transport_type == "input": + transport = await self._create_input_transport(params=params) + else: + transport = await self._create_output_transport(params=params) + client = transport._client + + # Create a StartFrame with specific sample rates + start_frame = StartFrame(audio_in_sample_rate=22050, audio_out_sample_rate=44100) + + with patch.object(client, "_sdk_connect", AsyncMock()): + await transport.start(start_frame) + + # Verify both sample rates were set from the StartFrame + assert client._audio_in_sample_rate == 22050 + assert client._audio_out_sample_rate == 44100 + + @pytest.mark.asyncio + @pytest.mark.parametrize("transport_type", ["input", "output"]) + async def test_vonage_transport_doesnt_override_audio_sample_rates( + self, transport_type: str + ) -> None: + """Test transport doesn't override audio sample rates when already set in params.""" + # Create params with specific sample rates + params = self.VonageVideoConnectorTransportParams( + audio_in_enabled=(transport_type == "input"), + audio_out_enabled=(transport_type == "output"), + audio_in_sample_rate=48000, + audio_out_sample_rate=16000, + ) + transport: VonageVideoConnectorInputTransport | VonageVideoConnectorOutputTransport + if transport_type == "input": + transport = await self._create_input_transport(params=params) + else: + transport = await self._create_output_transport(params=params) + client = transport._client + + # Create a StartFrame with different sample rates + start_frame = StartFrame(audio_in_sample_rate=22050, audio_out_sample_rate=44100) + + with patch.object(client, "_sdk_connect", AsyncMock()): + await transport.start(start_frame) + + # Verify sample rates remain as originally set in params + assert client._audio_in_sample_rate == 48000 + assert client._audio_out_sample_rate == 16000 + + @pytest.mark.asyncio + async def test_vonage_transport_initialization(self) -> None: + """Test VonageVideoConnectorTransport initialization.""" + params = self.VonageVideoConnectorTransportParams( + audio_out_sample_rate=48000, + audio_out_channels=2, + audio_out_enabled=True, + session_enable_migration=True, + publisher_name="test-publisher", + publisher_enable_opus_dtx=True, + ) + transport = await self._create_transport(params=params) + + assert transport._client is not None + assert transport._one_stream_received is False + + # Verify vonage client was initialized with correct parameters + client_params = transport._client._params + assert client_params.audio_out_sample_rate == 48000 + assert client_params.audio_out_channels == 2 + assert client_params.session_enable_migration is True + + @pytest.mark.asyncio + async def test_vonage_transport_input_output_methods(self) -> None: + """Test VonageVideoConnectorTransport input and output methods.""" + params = self.VonageVideoConnectorTransportParams() + transport = self.VonageVideoConnectorTransport( + self.application_id, self.session_id, self.token, params + ) + + # Test input method + input_transport = transport.input() + assert isinstance(input_transport, self.VonageVideoConnectorInputTransport) + + # Test output method + output_transport = transport.output() + assert isinstance(output_transport, self.VonageVideoConnectorOutputTransport) + + # Verify they return the same instances on subsequent calls + assert transport.input() is input_transport + assert transport.output() is output_transport + + @pytest.mark.asyncio + async def test_vonage_input_audio_callback(self) -> None: + """Test audio input callback processing.""" + + params = self.VonageVideoConnectorTransportParams( + audio_in_enabled=True, + ) + transport = await self._create_input_transport(params) + client = transport._client + + with ( + patch.object(transport, "push_audio_frame", AsyncMock()) as mock_push_audio_frame, + patch.object(client, "connect", AsyncMock(return_value=1)), + ): + start_frame = StartFrame() + await transport.start(start_frame) + + # Create mock audio data + audio_buffer = np.array([100, 200, 300, 400], dtype=np.int16) + audio_frame = InputAudioRawFrame( + audio=audio_buffer.tobytes(), sample_rate=48000, num_channels=2 + ) + + # Call the audio callback + await transport._audio_in_cb( + vonage_video_mock.models.Session(id="session"), audio_frame + ) + + mock_push_audio_frame.assert_called_once_with(audio_frame) + + @pytest.mark.asyncio + async def test_vonage_input_video_callback(self) -> None: + """Test video input callback processing.""" + + params = self.VonageVideoConnectorTransportParams( + video_in_enabled=True, + ) + transport = await self._create_input_transport(params) + client = transport._client + + with ( + patch.object(transport, "push_video_frame", AsyncMock()) as mock_push_video_frame, + patch.object(client, "connect", AsyncMock(return_value=1)), + ): + start_frame = StartFrame() + await transport.start(start_frame) + + # Create mock video frame + width, height = 640, 480 + rgb_image = np.zeros((height, width, 3), dtype=np.uint8) + rgb_image[:, :, 0] = 100 # R channel + rgb_image[:, :, 1] = 150 # G channel + rgb_image[:, :, 2] = 200 # B channel + + video_frame = UserImageRawFrame( + user_id="test-user", image=rgb_image.tobytes(), size=(width, height), format="RGB" + ) + + # Create mock subscriber + stream = vonage_video_mock.models.Stream(id="video_stream", connection=DUMMY_CONNECTION) + subscriber = vonage_video_mock.models.Subscriber(stream=stream) + + # Call the video callback + await transport._video_in_cb(subscriber, video_frame) + + mock_push_video_frame.assert_called_once_with(video_frame) + + @pytest.mark.asyncio + async def test_vonage_transport_event_handlers(self) -> None: + """Test VonageVideoConnectorTransport event handlers.""" + params = self.VonageVideoConnectorTransportParams() + transport = await self._create_transport(params) + + with patch.object( + transport, "_call_event_handler", new_callable=AsyncMock + ) as mock_call_event_handler: + # Test session events + mock_session = Mock() + mock_session.id = "session-123" + + await transport._on_connected(mock_session) + mock_call_event_handler.assert_called_with("on_joined", {"sessionId": "session-123"}) + + await transport._on_disconnected(mock_session) + mock_call_event_handler.assert_called_with("on_left", {"sessionId": "session-123"}) + + await transport._on_error(mock_session, "test error", 500) + mock_call_event_handler.assert_called_with("on_error", "test error") + + # Test stream events + mock_connection = Mock() + mock_connection.data = "connection-data-123" + mock_stream = Mock() + mock_stream.id = "stream-456" + mock_stream.connection = mock_connection + + await transport._on_stream_received(mock_session, mock_stream) + # Should call both first participant and participant joined events + expected_calls = [ + call( + "on_first_participant_joined", + { + "sessionId": "session-123", + "streamId": "stream-456", + "connectionData": "connection-data-123", + }, + ), + call( + "on_participant_joined", + { + "sessionId": "session-123", + "streamId": "stream-456", + "connectionData": "connection-data-123", + }, + ), + ] + mock_call_event_handler.assert_has_calls(expected_calls) + + await transport._on_stream_dropped(mock_session, mock_stream) + mock_call_event_handler.assert_called_with( + "on_participant_left", + { + "sessionId": "session-123", + "streamId": "stream-456", + "connectionData": "connection-data-123", + }, + ) + + # Test subscriber events + mock_subscriber = Mock() + mock_subscriber.stream = Mock() + mock_subscriber.stream.id = "subscriber-789" + mock_subscriber.stream.connection = Mock() + mock_subscriber.stream.connection.data = "subscriber-conn-data" + + await transport._on_subscriber_connected(mock_subscriber) + mock_call_event_handler.assert_called_with( + "on_client_connected", + { + "subscriberId": "subscriber-789", + "streamId": "subscriber-789", + "connectionData": "subscriber-conn-data", + }, + ) + + await transport._on_subscriber_disconnected(mock_subscriber) + mock_call_event_handler.assert_called_with( + "on_client_disconnected", + { + "subscriberId": "subscriber-789", + "streamId": "subscriber-789", + "connectionData": "subscriber-conn-data", + }, + ) + + @pytest.mark.asyncio + async def test_vonage_transport_first_participant_flag(self) -> None: + """Test that first participant event is only called once.""" + params = self.VonageVideoConnectorTransportParams() + transport = await self._create_transport(params) + + with patch.object( + transport, "_call_event_handler", new_callable=AsyncMock + ) as mock_call_event_handler: + mock_session = Mock() + mock_session.id = "session-123" + + mock_connection1 = Mock() + mock_connection1.data = "conn-data-1" + mock_stream1 = Mock() + mock_stream1.id = "stream-456" + mock_stream1.connection = mock_connection1 + + mock_connection2 = Mock() + mock_connection2.data = "conn-data-2" + mock_stream2 = Mock() + mock_stream2.id = "stream-789" + mock_stream2.connection = mock_connection2 + + # First stream should trigger first participant event + await transport._on_stream_received(mock_session, mock_stream1) + assert transport._one_stream_received is True + + # Reset mock to check second stream + mock_call_event_handler.reset_mock() + + # Second stream should not trigger first participant event + await transport._on_stream_received(mock_session, mock_stream2) + mock_call_event_handler.assert_called_once_with( + "on_participant_joined", + { + "sessionId": "session-123", + "streamId": "stream-789", + "connectionData": "conn-data-2", + }, + ) + + +class TestAudioNormalization: + """Test cases for audio normalization functions.""" + + def setup_method(self) -> None: + """Set up test fixtures.""" + self.AudioProps = AudioProps + self.process_audio_channels = process_audio_channels + self.process_audio = process_audio + self.check_audio_data = check_audio_data + + def test_audio_props_creation(self) -> None: + """Test AudioProps dataclass creation.""" + props = self.AudioProps(sample_rate=48000, is_stereo=True) + assert props.sample_rate == 48000 + assert props.is_stereo is True + + props_mono = self.AudioProps(sample_rate=16000, is_stereo=False) + assert props_mono.sample_rate == 16000 + assert props_mono.is_stereo is False + + def test_process_audio_channels_mono_to_stereo(self) -> None: + """Test converting mono audio to stereo.""" + # Create mono audio (4 samples) + mono_audio = np.array([100, 200, 300, 400], dtype=np.int16) + + current = self.AudioProps(sample_rate=48000, is_stereo=False) + target = self.AudioProps(sample_rate=48000, is_stereo=True) + + result = self.process_audio_channels(mono_audio, current, target) + + # Should duplicate each sample + expected = np.array([100, 100, 200, 200, 300, 300, 400, 400], dtype=np.int16) + np.testing.assert_array_equal(result, expected) + + def test_process_audio_channels_stereo_to_mono(self) -> None: + """Test converting stereo audio to mono.""" + # Create stereo audio (2 frames, 4 samples total) + stereo_audio = np.array([100, 200, 300, 400], dtype=np.int16) + + current = self.AudioProps(sample_rate=48000, is_stereo=True) + target = self.AudioProps(sample_rate=48000, is_stereo=False) + + result = self.process_audio_channels(stereo_audio, current, target) + + # Should average each stereo pair: (100+200)/2=150, (300+400)/2=350 + expected = np.array([150, 350], dtype=np.int16) + np.testing.assert_array_equal(result, expected) + + def test_process_audio_channels_same_format(self) -> None: + """Test when source and target have the same channel format.""" + audio = np.array([100, 200, 300, 400], dtype=np.int16) + + # Test mono to mono + current = self.AudioProps(sample_rate=48000, is_stereo=False) + target = self.AudioProps(sample_rate=48000, is_stereo=False) + result = self.process_audio_channels(audio, current, target) + np.testing.assert_array_equal(result, audio) + + # Test stereo to stereo + current = self.AudioProps(sample_rate=48000, is_stereo=True) + target = self.AudioProps(sample_rate=48000, is_stereo=True) + result = self.process_audio_channels(audio, current, target) + np.testing.assert_array_equal(result, audio) + + @pytest.mark.asyncio + @patch("pipecat.transports.vonage.client.create_stream_resampler") + async def test_process_audio_same_sample_rate(self, mock_resampler: MagicMock) -> None: + """Test process_audio when sample rates are the same.""" + mock_resampler_instance = Mock() + mock_resampler.return_value = mock_resampler_instance + + audio = np.array([100, 200, 300, 400], dtype=np.int16) + current = self.AudioProps(sample_rate=48000, is_stereo=False) + target = self.AudioProps(sample_rate=48000, is_stereo=True) + + result = await self.process_audio(mock_resampler_instance, audio, current, target) + + # Should only do channel conversion, no resampling + expected = np.array([100, 100, 200, 200, 300, 300, 400, 400], dtype=np.int16) + np.testing.assert_array_equal(result, expected) + + # Resampler should not be called + mock_resampler_instance.resample.assert_not_called() + + @pytest.mark.asyncio + @patch("pipecat.transports.vonage.client.create_stream_resampler") + async def test_process_audio_different_sample_rate_mono( + self, mock_resampler: MagicMock + ) -> None: + """Test process_audio with different sample rates (mono).""" + mock_resampler_instance = Mock() + mock_resampler_instance.resample = AsyncMock( + return_value=b"\x64\x00\xc8\x00" + ) # 100, 200 in bytes + mock_resampler.return_value = mock_resampler_instance + + audio = np.array([150, 250, 350, 450], dtype=np.int16) + current = self.AudioProps(sample_rate=48000, is_stereo=False) + target = self.AudioProps(sample_rate=16000, is_stereo=False) + + result = await self.process_audio(mock_resampler_instance, audio, current, target) + + # Should resample the audio + expected = np.array([100, 200], dtype=np.int16) + np.testing.assert_array_equal(result, expected) + + # Resampler should be called with correct parameters + mock_resampler_instance.resample.assert_called_once_with(audio.tobytes(), 48000, 16000) + + @pytest.mark.asyncio + @patch("pipecat.transports.vonage.client.create_stream_resampler") + async def test_process_audio_different_sample_rate_stereo_to_mono( + self, mock_resampler: MagicMock + ) -> None: + """Test process_audio with different sample rates and channel conversion.""" + mock_resampler_instance = Mock() + # Return resampled mono data + mock_resampler_instance.resample = AsyncMock( + return_value=b"\x64\x00\xc8\x00" + ) # 100, 200 in bytes + mock_resampler.return_value = mock_resampler_instance + + # Stereo audio: 2 frames with left/right channels + audio = np.array([100, 200, 300, 400], dtype=np.int16) # L1=100, R1=200, L2=300, R2=400 + current = self.AudioProps(sample_rate=48000, is_stereo=True) + target = self.AudioProps(sample_rate=16000, is_stereo=False) + + result = await self.process_audio(mock_resampler_instance, audio, current, target) + + # Should convert to mono first, then resample + expected = np.array([100, 200], dtype=np.int16) + np.testing.assert_array_equal(result, expected) + + # Resampler should be called with mono audio + expected_mono = np.array([150, 350], dtype=np.int16) # (100+200)/2, (300+400)/2 + mock_resampler_instance.resample.assert_called_once_with( + expected_mono.tobytes(), 48000, 16000 + ) + + @pytest.mark.asyncio + @patch("pipecat.transports.vonage.client.create_stream_resampler") + async def test_process_audio_different_sample_rate_mono_to_stereo( + self, mock_resampler: MagicMock + ) -> None: + """Test process_audio with different sample rates converting mono to stereo.""" + mock_resampler_instance = Mock() + # Return resampled mono data + mock_resampler_instance.resample = AsyncMock( + return_value=b"\x64\x00\xc8\x00" + ) # 100, 200 in bytes + mock_resampler.return_value = mock_resampler_instance + + audio = np.array([150, 250], dtype=np.int16) + current = self.AudioProps(sample_rate=48000, is_stereo=False) + target = self.AudioProps(sample_rate=16000, is_stereo=True) + + result = await self.process_audio(mock_resampler_instance, audio, current, target) + + # Should resample first (mono), then convert to stereo + expected = np.array([100, 100, 200, 200], dtype=np.int16) + np.testing.assert_array_equal(result, expected) + + # Resampler should be called with mono audio + mock_resampler_instance.resample.assert_called_once_with(audio.tobytes(), 48000, 16000) + + def test_check_audio_data_valid_mono_bytes(self) -> None: + """Test check_audio_data with valid mono audio as bytes.""" + # 4 frames of mono 16-bit audio (8 bytes total) + buffer = b"\x00\x01\x02\x03\x04\x05\x06\x07" + + # Should not raise any exception + self.check_audio_data(buffer, 4, 1) + + def test_check_audio_data_valid_stereo_bytes(self) -> None: + """Test check_audio_data with valid stereo audio as bytes.""" + # 2 frames of stereo 16-bit audio (8 bytes total) + buffer = b"\x00\x01\x02\x03\x04\x05\x06\x07" + + # Should not raise any exception + self.check_audio_data(buffer, 2, 2) + + def test_check_audio_data_valid_memoryview(self) -> None: + """Test check_audio_data with valid audio as memoryview.""" + # Create int16 memoryview (2 bytes per sample) + array = np.array([100, 200, 300, 400], dtype=np.int16) + buffer = memoryview(array) + + # Should not raise any exception + self.check_audio_data(buffer, 4, 1) # 4 mono frames + self.check_audio_data(buffer, 2, 2) # 2 stereo frames + + def test_check_audio_data_invalid_channels(self) -> None: + """Test check_audio_data with invalid number of channels.""" + buffer = b"\x00\x01\x02\x03" + + # Should raise ValueError for invalid channel counts + with pytest.raises(ValueError) as exc_info: + self.check_audio_data(buffer, 2, 3) # 3 channels not supported + assert "mono or stereo" in str(exc_info.value) + + with pytest.raises(ValueError) as exc_info: + self.check_audio_data(buffer, 2, 0) # 0 channels not supported + assert "mono or stereo" in str(exc_info.value) + + def test_check_audio_data_invalid_bit_depth_bytes(self) -> None: + """Test check_audio_data with invalid bit depth using bytes.""" + # 2 frames of mono audio with 1 byte per sample (8-bit) + buffer = b"\x00\x01" + + with pytest.raises(ValueError) as exc_info: + self.check_audio_data(buffer, 2, 1) + assert "16 bit PCM" in str(exc_info.value) + assert "got 8 bit" in str(exc_info.value) + + def test_check_audio_data_invalid_bit_depth_memoryview(self) -> None: + """Test check_audio_data with invalid bit depth using memoryview.""" + # Create uint8 memoryview (1 byte per sample) + array = np.array([100, 200], dtype=np.uint8) + buffer = memoryview(array) + + with pytest.raises(ValueError) as exc_info: + self.check_audio_data(buffer, 2, 1) + assert "16 bit PCM" in str(exc_info.value) + assert "got 8 bit" in str(exc_info.value) + + def test_check_audio_data_buffer_size_mismatch(self) -> None: + """Test check_audio_data with buffer size that doesn't match expected size.""" + # 3 bytes total, but expecting 2 frames of mono 16-bit (should be 4 bytes) + buffer = b"\x00\x01\x02" + + with pytest.raises(ValueError) as exc_info: + self.check_audio_data(buffer, 2, 1) + # Should detect that 3 bytes / (2 frames * 1 channel) = 1.5 bytes per sample + # which gets truncated to 1 byte per sample = 8 bit + assert "16 bit PCM" in str(exc_info.value) + + +class TestColorspaceConversion: + """Test cases for image colorspace conversion functions.""" + + def test_same_format_no_conversion(self) -> None: + """Test that conversion with same source and target format returns original image.""" + width, height = 4, 4 + image_data = np.random.randint(0, 256, width * height * 3, dtype=np.uint8).tobytes() + + # Test all formats with themselves + for fmt in ImageFormat: + result = image_colorspace_conversion(image_data, (width, height), fmt, fmt) + assert result == image_data + + def test_rgb_to_bgr_conversion(self) -> None: + """Test RGB to BGR conversion.""" + width, height = 2, 2 + # Create a simple RGB image with distinct colors + rgb_image = np.array( + [ + [[255, 0, 0], [0, 255, 0]], # Red, Green + [[0, 0, 255], [255, 255, 0]], # Blue, Yellow + ], + dtype=np.uint8, + ) + + result = image_colorspace_conversion( + rgb_image.tobytes(), + (width, height), + ImageFormat.RGB, + ImageFormat.BGR, + ) + + # Expected BGR: R and B channels swapped + expected = np.array( + [ + [[0, 0, 255], [0, 255, 0]], # Blue, Green (unchanged) + [[255, 0, 0], [0, 255, 255]], # Red, Cyan + ], + dtype=np.uint8, + ) + + assert result is not None + result_array = np.frombuffer(result, dtype=np.uint8).reshape(height, width, 3) + np.testing.assert_array_equal(result_array, expected) + + def test_bgr_to_rgb_conversion(self) -> None: + """Test BGR to RGB conversion (should be same as RGB to BGR).""" + width, height = 2, 2 + bgr_image = np.array( + [ + [[255, 0, 0], [0, 255, 0]], + [[0, 0, 255], [255, 255, 0]], + ], + dtype=np.uint8, + ) + + result = image_colorspace_conversion( + bgr_image.tobytes(), + (width, height), + ImageFormat.BGR, + ImageFormat.RGB, + ) + + # R and B channels should be swapped + expected = np.array( + [ + [[0, 0, 255], [0, 255, 0]], + [[255, 0, 0], [0, 255, 255]], + ], + dtype=np.uint8, + ) + + assert result is not None + result_array = np.frombuffer(result, dtype=np.uint8).reshape(height, width, 3) + np.testing.assert_array_equal(result_array, expected) + + def test_rgba_to_bgra_conversion(self) -> None: + """Test RGBA to BGRA conversion.""" + width, height = 2, 2 + rgba_image = np.array( + [ + [[255, 0, 0, 255], [0, 255, 0, 200]], # Red opaque, Green semi-transparent + [[0, 0, 255, 150], [255, 255, 0, 100]], # Blue, Yellow + ], + dtype=np.uint8, + ) + + result = image_colorspace_conversion( + rgba_image.tobytes(), + (width, height), + ImageFormat.RGBA, + ImageFormat.BGRA, + ) + + # Expected: R and B swapped, alpha unchanged + expected = np.array( + [ + [[0, 0, 255, 255], [0, 255, 0, 200]], + [[255, 0, 0, 150], [0, 255, 255, 100]], + ], + dtype=np.uint8, + ) + + assert result is not None + result_array = np.frombuffer(result, dtype=np.uint8).reshape(height, width, 4) + np.testing.assert_array_equal(result_array, expected) + + def test_bgra_to_rgba_conversion(self) -> None: + """Test BGRA to RGBA conversion.""" + width, height = 2, 2 + bgra_image = np.array( + [ + [[255, 0, 0, 255], [0, 255, 0, 200]], + [[0, 0, 255, 150], [255, 255, 0, 100]], + ], + dtype=np.uint8, + ) + + result = image_colorspace_conversion( + bgra_image.tobytes(), + (width, height), + ImageFormat.BGRA, + ImageFormat.RGBA, + ) + + expected = np.array( + [ + [[0, 0, 255, 255], [0, 255, 0, 200]], + [[255, 0, 0, 150], [0, 255, 255, 100]], + ], + dtype=np.uint8, + ) + + assert result is not None + result_array = np.frombuffer(result, dtype=np.uint8).reshape(height, width, 4) + np.testing.assert_array_equal(result_array, expected) + + def test_planar_yuv420_to_packed_yuv444_conversion(self) -> None: + """Test planar YUV420 to packed YUV444 conversion.""" + width, height = 4, 4 + + # Create YUV420 planar data + # Y plane: 4x4 = 16 bytes + y_plane = np.array( + [100, 110, 120, 130, 140, 150, 160, 170, 180, 190, 200, 210, 220, 230, 240, 250], + dtype=np.uint8, + ) + + # U plane: 2x2 = 4 bytes (subsampled) + u_plane = np.array([50, 60, 70, 80], dtype=np.uint8) + + # V plane: 2x2 = 4 bytes (subsampled) + v_plane = np.array([90, 100, 110, 120], dtype=np.uint8) + + yuv420_data = np.concatenate([y_plane, u_plane, v_plane]) + + result = image_colorspace_conversion( + yuv420_data.tobytes(), + (width, height), + ImageFormat.PLANAR_YUV420, + ImageFormat.PACKED_YUV444, + ) + + assert result is not None + result_array = np.frombuffer(result, dtype=np.uint8).reshape(height, width, 3) + + # Check that Y plane values are preserved + assert result_array[0, 0, 0] == 100 + assert result_array[0, 1, 0] == 110 + assert result_array[3, 3, 0] == 250 + + # Check that U and V planes are upsampled (each 2x2 block should have same U/V values) + # Top-left 2x2 block should have U=50, V=90 + assert result_array[0, 0, 1] == 50 + assert result_array[0, 0, 2] == 90 + assert result_array[0, 1, 1] == 50 + assert result_array[0, 1, 2] == 90 + assert result_array[1, 0, 1] == 50 + assert result_array[1, 0, 2] == 90 + assert result_array[1, 1, 1] == 50 + assert result_array[1, 1, 2] == 90 + + # Top-right 2x2 block should have U=60, V=100 + assert result_array[0, 2, 1] == 60 + assert result_array[0, 2, 2] == 100 + + def test_packed_yuv444_to_planar_yuv420_conversion(self) -> None: + """Test packed YUV444 to planar YUV420 conversion.""" + width, height = 4, 4 + + # Create packed YUV444 data (interleaved YUVYUVYUV...) + # Each pixel has Y, U, V values + packed_yuv444 = np.zeros((height, width, 3), dtype=np.uint8) + + # Set Y values + packed_yuv444[:, :, 0] = np.arange(100, 100 + width * height, dtype=np.uint8).reshape( + height, width + ) + + # Set U values (will be downsampled) + packed_yuv444[0:2, 0:2, 1] = 50 # Top-left block + packed_yuv444[0:2, 2:4, 1] = 60 # Top-right block + packed_yuv444[2:4, 0:2, 1] = 70 # Bottom-left block + packed_yuv444[2:4, 2:4, 1] = 80 # Bottom-right block + + # Set V values (will be downsampled) + packed_yuv444[0:2, 0:2, 2] = 90 + packed_yuv444[0:2, 2:4, 2] = 100 + packed_yuv444[2:4, 0:2, 2] = 110 + packed_yuv444[2:4, 2:4, 2] = 120 + + result = image_colorspace_conversion( + packed_yuv444.tobytes(), + (width, height), + ImageFormat.PACKED_YUV444, + ImageFormat.PLANAR_YUV420, + ) + + assert result is not None + + # Parse the planar YUV420 result + y_plane_size = width * height + uv_plane_size = (width // 2) * (height // 2) + + result_array = np.frombuffer(result, dtype=np.uint8) + y_result = result_array[:y_plane_size] + u_result = result_array[y_plane_size : y_plane_size + uv_plane_size] + v_result = result_array[y_plane_size + uv_plane_size :] + + # Check Y plane is preserved + expected_y = np.arange(100, 100 + width * height, dtype=np.uint8) + np.testing.assert_array_equal(y_result, expected_y) + + # Check U plane is downsampled (should be 2x2) + expected_u = np.array([50, 60, 70, 80], dtype=np.uint8) + np.testing.assert_array_equal(u_result, expected_u) + + # Check V plane is downsampled + expected_v = np.array([90, 100, 110, 120], dtype=np.uint8) + np.testing.assert_array_equal(v_result, expected_v) + + def test_unsupported_conversion_returns_none(self) -> None: + """Test that unsupported conversions return None.""" + width, height = 4, 4 + image_data = np.random.randint(0, 256, width * height * 3, dtype=np.uint8).tobytes() + + # Test some unsupported conversions + result = image_colorspace_conversion( + image_data, + (width, height), + ImageFormat.RGB, + ImageFormat.PLANAR_YUV420, + ) + assert result is None + + result = image_colorspace_conversion( + image_data, + (width, height), + ImageFormat.RGBA, + ImageFormat.RGB, + ) + assert result is None + + result = image_colorspace_conversion( + image_data, + (width, height), + ImageFormat.PLANAR_YUV420, + ImageFormat.BGR, + ) + assert result is None + + def test_conversion_with_different_sizes(self) -> None: + """Test conversions work with different image sizes.""" + test_sizes = [(2, 2), (4, 4), (8, 8), (16, 16)] + + for width, height in test_sizes: + # Test RGB to BGR + rgb_image = np.random.randint(0, 256, (height, width, 3), dtype=np.uint8) + result = image_colorspace_conversion( + rgb_image.tobytes(), + (width, height), + ImageFormat.RGB, + ImageFormat.BGR, + ) + assert result is not None + assert len(result) == width * height * 3 + + def test_yuv420_to_yuv444_roundtrip_preserves_y_plane(self) -> None: + """Test that Y plane is preserved in YUV420 -> YUV444 -> YUV420 conversion.""" + width, height = 4, 4 + + # Create original YUV420 data + y_plane_orig = np.arange(0, width * height, dtype=np.uint8) + u_plane_orig = np.array([50, 60, 70, 80], dtype=np.uint8) + v_plane_orig = np.array([90, 100, 110, 120], dtype=np.uint8) + yuv420_orig = np.concatenate([y_plane_orig, u_plane_orig, v_plane_orig]) + + # Convert to YUV444 + yuv444 = image_colorspace_conversion( + yuv420_orig.tobytes(), + (width, height), + ImageFormat.PLANAR_YUV420, + ImageFormat.PACKED_YUV444, + ) + assert yuv444 is not None + + # Convert back to YUV420 + yuv420_result = image_colorspace_conversion( + yuv444, + (width, height), + ImageFormat.PACKED_YUV444, + ImageFormat.PLANAR_YUV420, + ) + assert yuv420_result is not None + + # Extract Y plane from result + result_array = np.frombuffer(yuv420_result, dtype=np.uint8) + y_plane_result = result_array[: width * height] + + # Y plane should be identical after roundtrip + np.testing.assert_array_equal(y_plane_result, y_plane_orig) + + def test_rgb_bgr_roundtrip(self) -> None: + """Test that RGB -> BGR -> RGB conversion preserves data.""" + width, height = 4, 4 + rgb_orig = np.random.randint(0, 256, (height, width, 3), dtype=np.uint8) + + # Convert to BGR + bgr = image_colorspace_conversion( + rgb_orig.tobytes(), + (width, height), + ImageFormat.RGB, + ImageFormat.BGR, + ) + assert bgr is not None + + # Convert back to RGB + rgb_result = image_colorspace_conversion( + bgr, + (width, height), + ImageFormat.BGR, + ImageFormat.RGB, + ) + assert rgb_result is not None + + result_array = np.frombuffer(rgb_result, dtype=np.uint8).reshape(height, width, 3) + np.testing.assert_array_equal(result_array, rgb_orig) + + def test_rgba_bgra_roundtrip(self) -> None: + """Test that RGBA -> BGRA -> RGBA conversion preserves data.""" + width, height = 4, 4 + rgba_orig = np.random.randint(0, 256, (height, width, 4), dtype=np.uint8) + + # Convert to BGRA + bgra = image_colorspace_conversion( + rgba_orig.tobytes(), + (width, height), + ImageFormat.RGBA, + ImageFormat.BGRA, + ) + assert bgra is not None + + # Convert back to RGBA + rgba_result = image_colorspace_conversion( + bgra, + (width, height), + ImageFormat.BGRA, + ImageFormat.RGBA, + ) + assert rgba_result is not None + + result_array = np.frombuffer(rgba_result, dtype=np.uint8).reshape(height, width, 4) + np.testing.assert_array_equal(result_array, rgba_orig) From 0fd971d59db949757800fd4e37e7bb20a8ce6e34 Mon Sep 17 00:00:00 2001 From: Antoni Silvestre Date: Wed, 22 Apr 2026 16:58:42 +0200 Subject: [PATCH 02/12] Update src/pipecat/runner/types.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/pipecat/runner/types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pipecat/runner/types.py b/src/pipecat/runner/types.py index 6428ee507..bd39d71c6 100644 --- a/src/pipecat/runner/types.py +++ b/src/pipecat/runner/types.py @@ -101,7 +101,7 @@ class DailyRunnerArguments(RunnerArguments): @dataclass class VonageRunnerArguments(RunnerArguments): - """Daily transport session arguments for the runner. + """Vonage transport session arguments for the runner. Parameters: application_id: Vonage application ID From e3abb4b6d71db2bc54cda722f5bade3250dbd8c1 Mon Sep 17 00:00:00 2001 From: asilvestre Date: Sat, 25 Apr 2026 08:34:07 +0200 Subject: [PATCH 03/12] apply suggestions in PR --- src/pipecat/runner/utils.py | 2 +- src/pipecat/transports/vonage/client.py | 2 +- tests/test_vonage_video_connector.py | 6 ++++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/pipecat/runner/utils.py b/src/pipecat/runner/utils.py index 2334f6da6..2ba5ec31f 100644 --- a/src/pipecat/runner/utils.py +++ b/src/pipecat/runner/utils.py @@ -561,7 +561,7 @@ async def create_transport( audio_out_enabled=True, # add_wav_header and serializer will be set automatically ), - "vonage": lambda: VonageVideoConnectorParams( + "vonage": lambda: VonageVideoConnectorTransportParams( audio_in_enabled=True, audio_out_enabled=True, vad_analyzer=SileroVADAnalyzer(), diff --git a/src/pipecat/transports/vonage/client.py b/src/pipecat/transports/vonage/client.py index c6b703793..ecdfd24bc 100644 --- a/src/pipecat/transports/vonage/client.py +++ b/src/pipecat/transports/vonage/client.py @@ -864,7 +864,7 @@ class VonageClient: except Exception as exc: logger.error(f"Exception in SDK callback task: {exc}") finally: - active_tasks.discard(task) + active_tasks.discard(asyncio.current_task()) queue.task_done() try: diff --git a/tests/test_vonage_video_connector.py b/tests/test_vonage_video_connector.py index 39ca716ed..d13575f36 100644 --- a/tests/test_vonage_video_connector.py +++ b/tests/test_vonage_video_connector.py @@ -1539,8 +1539,10 @@ class TestVonageVideoConnectorTransport: # Wait for async processing await self._wait_for_condition( - lambda: on_stream_received_mock1.await_count > 0 - and on_stream_received_mock2.await_count > 0 + lambda: ( + on_stream_received_mock1.await_count > 0 + and on_stream_received_mock2.await_count > 0 + ) ) # Verify both listeners were called From 18368d047e8432dcca51ee1fea2476f7dce187b7 Mon Sep 17 00:00:00 2001 From: Antoni Silvestre Date: Wed, 22 Apr 2026 16:58:42 +0200 Subject: [PATCH 04/12] Linting and changes to adapt to v1.0 --- src/pipecat/transports/vonage/client.py | 46 +++++++++---------- .../transports/vonage/video_connector.py | 4 +- tests/test_vonage_video_connector.py | 33 ++++++------- 3 files changed, 42 insertions(+), 41 deletions(-) diff --git a/src/pipecat/transports/vonage/client.py b/src/pipecat/transports/vonage/client.py index ecdfd24bc..a53fa1b4c 100644 --- a/src/pipecat/transports/vonage/client.py +++ b/src/pipecat/transports/vonage/client.py @@ -8,12 +8,12 @@ import asyncio import itertools import threading -from collections.abc import Coroutine +from collections.abc import Awaitable, Callable, Coroutine from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass, replace from datetime import datetime, timedelta from enum import StrEnum -from typing import Any, Awaitable, Callable, Optional, TypeVar +from typing import Any, Optional, TypeVar import numpy as np from loguru import logger @@ -96,8 +96,8 @@ class VonageVideoConnectorTransportParams(TransportParams): audio_in_auto_subscribe: bool = True video_in_auto_subscribe: bool = False video_connector_log_level: str = "INFO" - video_in_preferred_resolution: Optional[tuple[int, int]] = None - video_in_preferred_framerate: Optional[int] = None + video_in_preferred_resolution: tuple[int, int] | None = None + video_in_preferred_framerate: int | None = None clear_buffers_on_interruption: bool = True @@ -114,8 +114,8 @@ class SubscribeSettings: subscribe_to_audio: bool = True subscribe_to_video: bool = False - preferred_resolution: Optional[tuple[int, int]] = None - preferred_framerate: Optional[int] = None + preferred_resolution: tuple[int, int] | None = None + preferred_framerate: int | None = None class VonageException(Exception): @@ -211,7 +211,7 @@ SimpleCoroutine = Coroutine[Any, Any, None] DUMMY_CONNECTION = Connection(id="", creation_time=datetime.min) -def _to_enum(value: Optional[str], enum_cls: type[TE]) -> Optional[TE]: +def _to_enum(value: str | None, enum_cls: type[TE]) -> TE | None: """Convert a string value to the specified StrEnum type, returning None if invalid.""" try: return enum_cls(value or "") @@ -259,25 +259,25 @@ class VonageClient: self._connected: bool = False self._connection_counter: int = 0 - self._connecting_future: Optional[asyncio.Future[None]] = None - self._disconnecting_future: Optional[asyncio.Future[None]] = None + self._connecting_future: asyncio.Future[None] | None = None + self._disconnecting_future: asyncio.Future[None] | None = None self._listener_id_gen: itertools.count[int] = itertools.count() self._listeners: dict[int, VonageClientListener] = {} - self._publisher: Optional[Publisher] = None + self._publisher: Publisher | None = None self._session = Session(id=session_id) self._resampler = create_stream_resampler() - self._task_manager: Optional[BaseTaskManager] = None + self._task_manager: BaseTaskManager | None = None self._loop_thread_id = threading.get_ident() - self._event_queue: Optional[asyncio.Queue[SimpleCoroutine]] = None - self._event_task: Optional[asyncio.Task[None]] = None - self._audio_queue: Optional[asyncio.Queue[SimpleCoroutine]] = None - self._audio_task: Optional[asyncio.Task[None]] = None - self._video_queue: Optional[asyncio.Queue[SimpleCoroutine]] = None - self._video_task: Optional[asyncio.Task[None]] = None + self._event_queue: asyncio.Queue[SimpleCoroutine] | None = None + self._event_task: asyncio.Task[None] | None = None + self._audio_queue: asyncio.Queue[SimpleCoroutine] | None = None + self._audio_task: asyncio.Task[None] | None = None + self._video_queue: asyncio.Queue[SimpleCoroutine] | None = None + self._video_task: asyncio.Task[None] | None = None # used for blocking calls to connect and disconnect self._executor = ThreadPoolExecutor(max_workers=1) @@ -366,7 +366,7 @@ class VonageClient: """ self._listeners.pop(listener_id, None) - async def connect(self, frame: Optional[StartFrame] = None) -> None: + async def connect(self, frame: StartFrame | None = None) -> None: """Connect to the Vonage session. Args: @@ -698,7 +698,7 @@ class VonageClient: try: await asyncio.wait_for(async_proc(), timeout=VIDEO_CONNECTOR_TIMEOUT.total_seconds()) - except asyncio.TimeoutError as exc: + except TimeoutError as exc: logger.error(f"Timeout connecting to Vonage session {self._session_id}") raise exc @@ -715,7 +715,7 @@ class VonageClient: self._get_event_loop().run_in_executor(self._executor, disconnect_proc), timeout=VIDEO_CONNECTOR_TIMEOUT.total_seconds(), ) - except asyncio.TimeoutError: + except TimeoutError: logger.error(f"Timeout disconnecting from Vonage session {self._session_id}") raise @@ -800,7 +800,7 @@ class VonageClient: try: await asyncio.wait_for(process(), timeout=VIDEO_CONNECTOR_TIMEOUT.total_seconds()) - except asyncio.TimeoutError: + except TimeoutError: logger.error(f"Timeout subscribing to Vonage stream {stream.id}") self._session_subscriptions.pop(stream.id, None) raise @@ -856,7 +856,7 @@ class VonageClient: self._loop_thread_id = threading.get_ident() # if we allow concurrent tasks, process them as they come in if allow_concurrent: - active_tasks = set() + active_tasks: set[asyncio.Task[Any]] = set() async def wrapped_task(coroutine: SimpleCoroutine) -> None: try: @@ -907,7 +907,7 @@ class VonageClient: def _sdk_cb_to_loop( self, queue_type_name: str, - queue: Optional[asyncio.Queue[SimpleCoroutine]], + queue: asyncio.Queue[SimpleCoroutine] | None, async_task: SimpleCoroutine, ) -> None: """From an SDK thread queue a coroutine to be asynchronously executed in the task manager event loop. diff --git a/src/pipecat/transports/vonage/video_connector.py b/src/pipecat/transports/vonage/video_connector.py index f84fc3406..cca211c04 100644 --- a/src/pipecat/transports/vonage/video_connector.py +++ b/src/pipecat/transports/vonage/video_connector.py @@ -360,8 +360,8 @@ class VonageVideoConnectorTransport(BaseTransport): ) ) - self._input: Optional[VonageVideoConnectorInputTransport] = None - self._output: Optional[VonageVideoConnectorOutputTransport] = None + self._input: VonageVideoConnectorInputTransport | None = None + self._output: VonageVideoConnectorOutputTransport | None = None self._one_stream_received: bool = False def input(self) -> FrameProcessor: diff --git a/tests/test_vonage_video_connector.py b/tests/test_vonage_video_connector.py index d13575f36..f8d874fa9 100644 --- a/tests/test_vonage_video_connector.py +++ b/tests/test_vonage_video_connector.py @@ -8,10 +8,11 @@ import asyncio import inspect import sys import threading +from collections.abc import Awaitable, Callable from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass from datetime import datetime, timedelta -from typing import Any, Awaitable, Callable, Optional +from typing import Any, Optional from unittest.mock import ANY, AsyncMock, MagicMock, Mock, call, patch import numpy as np @@ -76,7 +77,7 @@ class MockPublisher: @dataclass(eq=True, frozen=True) class MockSubscriber: - stream: Optional[MockStream] = None + stream: MockStream | None = None @dataclass(eq=True, frozen=True) @@ -100,9 +101,9 @@ class MockSessionVideoPublisherSettings: @dataclass(eq=True, frozen=True) class MockSessionAVSettings: - audio_publisher: Optional[MockSessionAudioSettings] = None - audio_subscribers_mix: Optional[MockSessionAudioSettings] = None - video_publisher: Optional[MockSessionVideoPublisherSettings] = None + audio_publisher: MockSessionAudioSettings | None = None + audio_subscribers_mix: MockSessionAudioSettings | None = None + video_publisher: MockSessionVideoPublisherSettings | None = None @dataclass(eq=True, frozen=True) @@ -113,8 +114,8 @@ class MockLoggingSettings: @dataclass(eq=True, frozen=True) class MockSessionSettings: enable_migration: bool = False - av: Optional[MockSessionAVSettings] = None - logging: Optional[MockLoggingSettings] = None + av: MockSessionAVSettings | None = None + logging: MockLoggingSettings | None = None @dataclass(eq=True, frozen=True) @@ -128,7 +129,7 @@ class MockPublisherSettings: name: str has_audio: bool has_video: bool - audio_settings: Optional[MockPublisherAudioSettings] = None + audio_settings: MockPublisherAudioSettings | None = None @dataclass(eq=True, frozen=True) @@ -140,15 +141,15 @@ class MockVideoFrame: @dataclass(eq=True, frozen=True) class MockSubscriberVideoSettings: - preferred_resolution: Optional[MockVideoResolution] = None - preferred_framerate: Optional[int] = None + preferred_resolution: MockVideoResolution | None = None + preferred_framerate: int | None = None @dataclass(eq=True, frozen=True) class MockSubscriberSettings: subscribe_to_audio: bool = True subscribe_to_video: bool = True - video_settings: Optional[MockSubscriberVideoSettings] = None + video_settings: MockSubscriberVideoSettings | None = None # Set up the mock module structure @@ -232,11 +233,11 @@ class TestVonageVideoConnectorTransport: self.application_id = "test-app-id" self.session_id = "test-session-id" self.token = "test-token" - self._frame_processor_setup: Optional[FrameProcessorSetup] = None + self._frame_processor_setup: FrameProcessorSetup | None = None self._executor = ThreadPoolExecutor(max_workers=1) # subscriber state - self._connect_callbacks: Optional[ConnectCallbacks] = None + self._connect_callbacks: ConnectCallbacks | None = None self._subscriber_callbacks: dict[str, SubscriberCallbacks] = {} def _get_frame_processor_setup(self) -> FrameProcessorSetup: @@ -271,7 +272,7 @@ class TestVonageVideoConnectorTransport: while not condition(): if asyncio.get_event_loop().time() - start_time > timeout_seconds: - raise asyncio.TimeoutError(f"Condition not met within {timeout}") + raise TimeoutError(f"Condition not met within {timeout}") await asyncio.sleep(check_interval_seconds) def test_vonage_client_listener_defaults(self) -> None: @@ -399,7 +400,7 @@ class TestVonageVideoConnectorTransport: async def _create_client( self, - params: Optional[VonageVideoConnectorTransportParams] = None, + params: VonageVideoConnectorTransportParams | None = None, setup_connect_mock: bool = True, ) -> VonageClient: params = params or VonageVideoConnectorTransportParams() @@ -788,7 +789,7 @@ class TestVonageVideoConnectorTransport: ) def connect_side_effect( - *_: Any, on_ready_for_audio_cb: Optional[Callable[[Any], None]] = None, **__: Any + *_: Any, on_ready_for_audio_cb: Callable[[Any], None] | None = None, **__: Any ) -> bool: assert on_ready_for_audio_cb is not None connecting_future.set_result(on_ready_for_audio_cb) From cab4585cbb9c17213c5f1137b090d133666bd007 Mon Sep 17 00:00:00 2001 From: asilvestre Date: Fri, 15 May 2026 10:08:18 +0200 Subject: [PATCH 05/12] added changelog --- changelog/4052.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/4052.added.md diff --git a/changelog/4052.added.md b/changelog/4052.added.md new file mode 100644 index 000000000..40f26a466 --- /dev/null +++ b/changelog/4052.added.md @@ -0,0 +1 @@ +- Added `VonageVideoConnectorTransport`, a new transport integration for real-time Vonage WebRTC sessions using the Vonage Video Connector library. From c4ff9300c9aa701d875e3577213ee846f8e0bf2e Mon Sep 17 00:00:00 2001 From: asilvestre Date: Fri, 15 May 2026 10:12:57 +0200 Subject: [PATCH 06/12] fix linting and typechecking --- src/pipecat/transports/vonage/client.py | 3 ++- src/pipecat/transports/vonage/video_connector.py | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/pipecat/transports/vonage/client.py b/src/pipecat/transports/vonage/client.py index a53fa1b4c..b3991d7f5 100644 --- a/src/pipecat/transports/vonage/client.py +++ b/src/pipecat/transports/vonage/client.py @@ -864,7 +864,8 @@ class VonageClient: except Exception as exc: logger.error(f"Exception in SDK callback task: {exc}") finally: - active_tasks.discard(asyncio.current_task()) + if (current := asyncio.current_task()) is not None: + active_tasks.discard(current) queue.task_done() try: diff --git a/src/pipecat/transports/vonage/video_connector.py b/src/pipecat/transports/vonage/video_connector.py index cca211c04..e7cb9296f 100644 --- a/src/pipecat/transports/vonage/video_connector.py +++ b/src/pipecat/transports/vonage/video_connector.py @@ -25,9 +25,9 @@ from pipecat.transports.base_input import BaseInputTransport from pipecat.transports.base_output import BaseOutputTransport from pipecat.transports.base_transport import BaseTransport from pipecat.transports.vonage.client import ( - Session, - Stream, - Subscriber, + Session, # type: ignore[attr-defined] + Stream, # type: ignore[attr-defined] + Subscriber, # type: ignore[attr-defined] VonageClient, VonageClientListener, ) From a1c40df4719b004106a182d25298a4668b39ef70 Mon Sep 17 00:00:00 2001 From: asilvestre Date: Fri, 15 May 2026 11:34:02 +0200 Subject: [PATCH 07/12] add documentation entry --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index cfc5a8d9a..e2591a7dc 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ Catch new features, interviews, and how-tos on our [Pipecat TV](https://www.yout | LLMs | [Anthropic](https://docs.pipecat.ai/api-reference/server/services/llm/anthropic), [AWS](https://docs.pipecat.ai/api-reference/server/services/llm/aws), [Azure](https://docs.pipecat.ai/api-reference/server/services/llm/azure), [Cerebras](https://docs.pipecat.ai/api-reference/server/services/llm/cerebras), [DeepSeek](https://docs.pipecat.ai/api-reference/server/services/llm/deepseek), [Fireworks AI](https://docs.pipecat.ai/api-reference/server/services/llm/fireworks), [Gemini](https://docs.pipecat.ai/api-reference/server/services/llm/gemini), [Grok](https://docs.pipecat.ai/api-reference/server/services/llm/grok), [Groq](https://docs.pipecat.ai/api-reference/server/services/llm/groq), [Mistral](https://docs.pipecat.ai/api-reference/server/services/llm/mistral), [Nebius](https://docs.pipecat.ai/api-reference/server/services/llm/nebius), [Novita](https://docs.pipecat.ai/api-reference/server/services/llm/novita), [NVIDIA NIM](https://docs.pipecat.ai/api-reference/server/services/llm/nvidia), [Ollama](https://docs.pipecat.ai/api-reference/server/services/llm/ollama), [OpenAI](https://docs.pipecat.ai/api-reference/server/services/llm/openai), [OpenAI Responses](https://docs.pipecat.ai/api-reference/server/services/llm/openai-responses), [OpenRouter](https://docs.pipecat.ai/api-reference/server/services/llm/openrouter), [Perplexity](https://docs.pipecat.ai/api-reference/server/services/llm/perplexity), [Qwen](https://docs.pipecat.ai/api-reference/server/services/llm/qwen), [SambaNova](https://docs.pipecat.ai/api-reference/server/services/llm/sambanova), [Sarvam](https://docs.pipecat.ai/api-reference/server/services/llm/sarvam), [Together AI](https://docs.pipecat.ai/api-reference/server/services/llm/together) | | Text-to-Speech | [Async](https://docs.pipecat.ai/api-reference/server/services/tts/asyncai), [AWS](https://docs.pipecat.ai/api-reference/server/services/tts/aws), [Azure](https://docs.pipecat.ai/api-reference/server/services/tts/azure), [Camb AI](https://docs.pipecat.ai/api-reference/server/services/tts/camb), [Cartesia](https://docs.pipecat.ai/api-reference/server/services/tts/cartesia), [Deepgram](https://docs.pipecat.ai/api-reference/server/services/tts/deepgram), [ElevenLabs](https://docs.pipecat.ai/api-reference/server/services/tts/elevenlabs), [Fish](https://docs.pipecat.ai/api-reference/server/services/tts/fish), [Google](https://docs.pipecat.ai/api-reference/server/services/tts/google), [Gradium](https://docs.pipecat.ai/api-reference/server/services/tts/gradium), [Groq](https://docs.pipecat.ai/api-reference/server/services/tts/groq), [Hume](https://docs.pipecat.ai/api-reference/server/services/tts/hume), [Inworld](https://docs.pipecat.ai/api-reference/server/services/tts/inworld), [Kokoro](https://docs.pipecat.ai/api-reference/server/services/tts/kokoro), [LMNT](https://docs.pipecat.ai/api-reference/server/services/tts/lmnt), [MiniMax](https://docs.pipecat.ai/api-reference/server/services/tts/minimax), [Mistral](https://docs.pipecat.ai/api-reference/server/services/tts/mistral), [Neuphonic](https://docs.pipecat.ai/api-reference/server/services/tts/neuphonic), [NVIDIA](https://docs.pipecat.ai/api-reference/server/services/tts/nvidia), [OpenAI](https://docs.pipecat.ai/api-reference/server/services/tts/openai), [Piper](https://docs.pipecat.ai/api-reference/server/services/tts/piper), [Resemble](https://docs.pipecat.ai/api-reference/server/services/tts/resemble), [Rime](https://docs.pipecat.ai/api-reference/server/services/tts/rime), [Sarvam](https://docs.pipecat.ai/api-reference/server/services/tts/sarvam), [Smallest](https://docs.pipecat.ai/api-reference/server/services/tts/smallest), [Soniox](https://docs.pipecat.ai/api-reference/server/services/tts/soniox), [Speechmatics](https://docs.pipecat.ai/api-reference/server/services/tts/speechmatics), [xAI](https://docs.pipecat.ai/api-reference/server/services/tts/xai), [XTTS](https://docs.pipecat.ai/api-reference/server/services/tts/xtts) | | Speech-to-Speech | [AWS Nova Sonic](https://docs.pipecat.ai/api-reference/server/services/s2s/aws), [Gemini Multimodal Live](https://docs.pipecat.ai/api-reference/server/services/s2s/gemini), [Grok Voice Agent](https://docs.pipecat.ai/api-reference/server/services/s2s/grok), [OpenAI Realtime](https://docs.pipecat.ai/api-reference/server/services/s2s/openai), [Ultravox](https://docs.pipecat.ai/api-reference/server/services/s2s/ultravox), | -| Transport | [Daily (WebRTC)](https://docs.pipecat.ai/api-reference/server/services/transport/daily), [FastAPI Websocket](https://docs.pipecat.ai/api-reference/server/services/transport/fastapi-websocket), [LiveKit (WebRTC)](https://docs.pipecat.ai/api-reference/server/services/transport/livekit), [SmallWebRTCTransport](https://docs.pipecat.ai/api-reference/server/services/transport/small-webrtc), [WebSocket Server](https://docs.pipecat.ai/api-reference/server/services/transport/websocket-server), [WhatsApp](https://docs.pipecat.ai/api-reference/server/services/transport/whatsapp), Local | +| Transport | [Daily (WebRTC)](https://docs.pipecat.ai/api-reference/server/services/transport/daily), [FastAPI Websocket](https://docs.pipecat.ai/api-reference/server/services/transport/fastapi-websocket), [LiveKit (WebRTC)](https://docs.pipecat.ai/api-reference/server/services/transport/livekit), [SmallWebRTCTransport](https://docs.pipecat.ai/api-reference/server/services/transport/small-webrtc), [Vonage (WebRTC)](https://docs.pipecat.ai/api-reference/server/services/transport/vonage), [WebSocket Server](https://docs.pipecat.ai/api-reference/server/services/transport/websocket-server), [WhatsApp](https://docs.pipecat.ai/api-reference/server/services/transport/whatsapp), Local | | Serializers | [Exotel](https://docs.pipecat.ai/api-reference/server/services/serializers/exotel), [Genesys](https://docs.pipecat.ai/api-reference/server/services/serializers/genesys), [Plivo](https://docs.pipecat.ai/api-reference/server/services/serializers/plivo), [Twilio](https://docs.pipecat.ai/api-reference/server/services/serializers/twilio), [Telnyx](https://docs.pipecat.ai/api-reference/server/services/serializers/telnyx), [Vonage](https://docs.pipecat.ai/api-reference/server/services/serializers/vonage) | | Video | [HeyGen](https://docs.pipecat.ai/api-reference/server/services/video/heygen), [LemonSlice](https://docs.pipecat.ai/api-reference/server/services/transport/lemonslice), [Tavus](https://docs.pipecat.ai/api-reference/server/services/video/tavus), [Simli](https://docs.pipecat.ai/api-reference/server/services/video/simli) | | Memory | [mem0](https://docs.pipecat.ai/api-reference/server/services/memory/mem0) | From dd38fbc7353862249f777dead74faea844bde4d9 Mon Sep 17 00:00:00 2001 From: asilvestre Date: Fri, 15 May 2026 11:28:11 +0200 Subject: [PATCH 08/12] add documentation entry --- examples/README.md | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/examples/README.md b/examples/README.md index 8d3ff3f1f..5ec86002e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -55,20 +55,6 @@ Then, run the example with: uv run getting-started/06-voice-agent.py -t twilio -x NGROK_HOST_NAME ``` -### Vonage - -It is also possible to run the example through a Vonage session. Just provide the values for the following variables in -the `.env` file: -* VONAGE_APPLICATION_ID -* VONAGE_SESSION_ID -* VONAGE_TOKEN - -Then, run the example with: - -```bash -uv run getting-started/06-voice-agent.py -t vonage -``` - ## Directory Structure ### [`getting-started/`](./getting-started/) From ee5aa4dc71c521c9b95fbe434d243a9de558964b Mon Sep 17 00:00:00 2001 From: asilvestre Date: Fri, 15 May 2026 11:29:30 +0200 Subject: [PATCH 09/12] SubscribeSettings to be pydantic and comment fixes --- src/pipecat/runner/utils.py | 3 +-- src/pipecat/transports/vonage/client.py | 10 +++++----- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/pipecat/runner/utils.py b/src/pipecat/runner/utils.py index 2ba5ec31f..34b66f63a 100644 --- a/src/pipecat/runner/utils.py +++ b/src/pipecat/runner/utils.py @@ -563,8 +563,7 @@ async def create_transport( ), "vonage": lambda: VonageVideoConnectorTransportParams( audio_in_enabled=True, - audio_out_enabled=True, - vad_analyzer=SileroVADAnalyzer(), + audio_out_enabled=True ), } diff --git a/src/pipecat/transports/vonage/client.py b/src/pipecat/transports/vonage/client.py index b3991d7f5..daa53050c 100644 --- a/src/pipecat/transports/vonage/client.py +++ b/src/pipecat/transports/vonage/client.py @@ -17,6 +17,7 @@ from typing import Any, Optional, TypeVar import numpy as np from loguru import logger +from pydantic import BaseModel from pipecat.audio.utils import create_stream_resampler from pipecat.frames.frames import ( @@ -70,7 +71,7 @@ try: except ModuleNotFoundError as e: logger.error(f"Exception: {e}") logger.error( - f"In order to use Vonage Video Connector, you need to have the Vonage Video Connector python library installed." + "In order to use Vonage Video Connector, you need to `pip install pipecat-ai[vonage-video-connector]`." ) raise Exception(f"Missing module: {e}") @@ -101,13 +102,12 @@ class VonageVideoConnectorTransportParams(TransportParams): clear_buffers_on_interruption: bool = True -@dataclass -class SubscribeSettings: +class SubscribeSettings(BaseModel): """Parameters for stream input subscription. Parameters: - capture_audio: Whether to subscribe to audio. - capture_video: Whether to subscribe to video. + subscribe_to_audio: Whether to subscribe to audio. + subscribe_to_video: Whether to subscribe to video. preferred_resolution: Preferred resolution for video subscription. preferred_framerate: Preferred framerate for video subscription. """ From bc769eaa82c42b6f177b744910afab5ef82004a9 Mon Sep 17 00:00:00 2001 From: asilvestre Date: Fri, 15 May 2026 15:03:32 +0200 Subject: [PATCH 10/12] Changing the example to use OpenAI --- examples/transports/transports-vonage.py | 82 ++++++++++++++++-------- src/pipecat/runner/run.py | 4 +- src/pipecat/runner/types.py | 4 +- src/pipecat/runner/utils.py | 2 +- 4 files changed, 59 insertions(+), 33 deletions(-) diff --git a/examples/transports/transports-vonage.py b/examples/transports/transports-vonage.py index 774ca1696..cf85d4a9a 100644 --- a/examples/transports/transports-vonage.py +++ b/examples/transports/transports-vonage.py @@ -4,7 +4,7 @@ # SPDX-License-Identifier: BSD 2-Clause License # -"""Example of using AWS Nova Sonic LLM service with Vonage Video Connector transport.""" +"""Example of using OpenAI Realtime voice LLM service with Vonage Video Connector transport.""" import asyncio import os @@ -17,16 +17,25 @@ from loguru import logger from pipecat.audio.vad.silero import SileroVADAnalyzer from pipecat.frames.frames import LLMRunFrame +from pipecat.observers.loggers.transcription_log_observer import TranscriptionLogObserver from pipecat.pipeline.pipeline import Pipeline from pipecat.pipeline.runner import PipelineRunner -from pipecat.pipeline.task import PipelineTask +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.vonage import configure -from pipecat.services.aws.nova_sonic.llm import AWSNovaSonicLLMService +from pipecat.services.openai.realtime.events import ( + AudioConfiguration, + AudioInput, + InputAudioNoiseReduction, + InputAudioTranscription, + SemanticTurnDetection, + SessionProperties, +) +from pipecat.services.openai.realtime.llm import OpenAIRealtimeLLMService from pipecat.transports.vonage.video_connector import ( VonageVideoConnectorTransport, VonageVideoConnectorTransportParams, @@ -39,15 +48,9 @@ logger.add(sys.stderr, level="DEBUG") async def main() -> None: - """Main entry point for the nova sonic vonage video connector example.""" + """Main entry point for the OpenAI Realtime vonage video connector example.""" (application_id, session_id, token) = await configure() - system_instruction = ( - "You are a friendly assistant. The user and you will engage in a spoken dialog exchanging " - "the transcripts of a natural real-time conversation. Keep your responses short, generally " - "two or three sentences for chatty scenarios. " - f"{AWSNovaSonicLLMService.AWAIT_TRIGGER_ASSISTANT_RESPONSE_INSTRUCTION}" - ) transport = VonageVideoConnectorTransport( application_id, session_id, @@ -59,24 +62,41 @@ async def main() -> None: ), ) - llm = AWSNovaSonicLLMService( - secret_access_key=os.getenv("AWS_SECRET_ACCESS_KEY", ""), - access_key_id=os.getenv("AWS_ACCESS_KEY_ID", ""), - region=os.getenv("AWS_REGION", ""), - session_token=os.getenv("AWS_SESSION_TOKEN", ""), - voice_id="tiffany", + llm = OpenAIRealtimeLLMService( + api_key=os.environ["OPENAI_API_KEY"], + settings=OpenAIRealtimeLLMService.Settings( + system_instruction="""You are a helpful and friendly AI. + +Act like a human, but remember that you aren't a human and that you can't do human +things in the real world. Your voice and personality should be warm and engaging, with a lively and +playful tone. + +If interacting in a non-English language, start by using the standard accent or dialect familiar to +the user. Talk quickly. + +You are participating in a voice conversation. Keep your responses concise, short, and to the point +unless specifically asked to elaborate on a topic. + +Remember, your responses should be short. Just one or two sentences, usually. Respond in English.""", + session_properties=SessionProperties( + audio=AudioConfiguration( + input=AudioInput( + transcription=InputAudioTranscription(), + turn_detection=SemanticTurnDetection(), + noise_reduction=InputAudioNoiseReduction(type="near_field"), + ) + ), + ), + ), ) + context = LLMContext( - messages=[ - {"role": "system", "content": f"{system_instruction}"}, - { - "role": "user", - "content": "Tell me a fun fact!", - }, - ], + [{"role": "developer", "content": "Say hello!"}], ) + user_aggregator, assistant_aggregator = LLMContextAggregatorPair( - context, user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()) + context, + user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()), ) pipeline = Pipeline( @@ -89,19 +109,25 @@ async def main() -> None: ] ) - task = PipelineTask(pipeline) + task = PipelineTask( + pipeline, + params=PipelineParams( + enable_metrics=True, + enable_usage_metrics=True, + ), + observers=[TranscriptionLogObserver()], + ) - # Handle client connection event event_handler: Callable[[str], Callable[[Any], Any]] = transport.event_handler @event_handler("on_client_connected") async def on_client_connected(transport: VonageVideoConnectorTransport, client: object) -> None: - logger.info(f"Client connected") + logger.info("Client connected") await task.queue_frames([LLMRunFrame()]) runner = PipelineRunner() - await asyncio.gather(runner.run(task)) + await runner.run(task) if __name__ == "__main__": diff --git a/src/pipecat/runner/run.py b/src/pipecat/runner/run.py index 872dca5af..f654f110d 100644 --- a/src/pipecat/runner/run.py +++ b/src/pipecat/runner/run.py @@ -991,14 +991,14 @@ async def _run_vonage(): application_id, session_id, token = await configure_vonage() runner_args = VonageRunnerArguments( - application_id=application_id, session_id=session_id, token=token + application_id=application_id, vonage_session_id=session_id, token=token ) runner_args.handle_sigint = True # Get the bot module and run it directly bot_module = _get_bot_module() - print(f"Joining Vonage session: {runner_args.session_id}") + print(f"Joining Vonage session: {runner_args.vonage_session_id}") print() await bot_module.bot(runner_args) diff --git a/src/pipecat/runner/types.py b/src/pipecat/runner/types.py index bd39d71c6..ebee43842 100644 --- a/src/pipecat/runner/types.py +++ b/src/pipecat/runner/types.py @@ -105,12 +105,12 @@ class VonageRunnerArguments(RunnerArguments): Parameters: application_id: Vonage application ID - session_id: Vonage session ID + vonage_session_id: Vonage session ID token: Vonage Session Token """ application_id: str - session_id: str + vonage_session_id: str token: str diff --git a/src/pipecat/runner/utils.py b/src/pipecat/runner/utils.py index 34b66f63a..a9267b68b 100644 --- a/src/pipecat/runner/utils.py +++ b/src/pipecat/runner/utils.py @@ -640,7 +640,7 @@ async def create_transport( return VonageVideoConnectorTransport( runner_args.application_id, - runner_args.session_id, + runner_args.vonage_session_id, runner_args.token, params=params, ) From 956b39b0dc61511a678f0b58a66151a3a9850e39 Mon Sep 17 00:00:00 2001 From: asilvestre Date: Tue, 19 May 2026 16:33:04 +0200 Subject: [PATCH 11/12] remove extraenous await in cleanup --- src/pipecat/transports/vonage/client.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/pipecat/transports/vonage/client.py b/src/pipecat/transports/vonage/client.py index daa53050c..38924e6d2 100644 --- a/src/pipecat/transports/vonage/client.py +++ b/src/pipecat/transports/vonage/client.py @@ -334,15 +334,12 @@ class VonageClient: if self._event_task and self._task_manager: await self._task_manager.cancel_task(self._event_task) - await self._event_task self._event_task = None if self._audio_task and self._task_manager: await self._task_manager.cancel_task(self._audio_task) - await self._audio_task self._audio_task = None if self._video_task and self._task_manager: await self._task_manager.cancel_task(self._video_task) - await self._video_task self._video_task = None def add_listener(self, listener: VonageClientListener) -> int: From e2d249e5d93e8968c635ecfe215c8aafde40fc29 Mon Sep 17 00:00:00 2001 From: asilvestre Date: Tue, 19 May 2026 16:33:38 +0200 Subject: [PATCH 12/12] adding uv.lock --- uv.lock | 222 +++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 178 insertions(+), 44 deletions(-) diff --git a/uv.lock b/uv.lock index 11ca03d4a..6e24f3964 100644 --- a/uv.lock +++ b/uv.lock @@ -307,7 +307,8 @@ dependencies = [ { name = "docstring-parser" }, { name = "httpx" }, { name = "jiter" }, - { name = "pydantic" }, + { name = "pydantic", version = "2.11.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.13.*'" }, + { name = "pydantic", version = "2.13.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.13.*'" }, { name = "sniffio" }, { name = "typing-extensions" }, ] @@ -616,7 +617,8 @@ version = "1.5.11" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, - { name = "pydantic" }, + { name = "pydantic", version = "2.11.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.13.*'" }, + { name = "pydantic", version = "2.13.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.13.*'" }, { name = "typing-extensions" }, { name = "websocket-client" }, { name = "websockets" }, @@ -1268,8 +1270,10 @@ version = "6.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, - { name = "pydantic" }, - { name = "pydantic-core" }, + { name = "pydantic", version = "2.11.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.13.*'" }, + { name = "pydantic", version = "2.13.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.13.*'" }, + { name = "pydantic-core", version = "2.33.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.13.*'" }, + { name = "pydantic-core", version = "2.46.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.13.*'" }, { name = "typing-extensions" }, { name = "websockets" }, ] @@ -1394,7 +1398,8 @@ version = "0.136.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, - { name = "pydantic" }, + { name = "pydantic", version = "2.11.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.13.*'" }, + { name = "pydantic", version = "2.13.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.13.*'" }, { name = "starlette" }, { name = "typing-extensions" }, { name = "typing-inspection" }, @@ -1445,7 +1450,8 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fastar" }, { name = "httpx" }, - { name = "pydantic", extra = ["email"] }, + { name = "pydantic", version = "2.11.10", source = { registry = "https://pypi.org/simple" }, extra = ["email"], marker = "python_full_version == '3.13.*'" }, + { name = "pydantic", version = "2.13.4", source = { registry = "https://pypi.org/simple" }, extra = ["email"], marker = "python_full_version != '3.13.*'" }, { name = "rich-toolkit" }, { name = "rignore" }, { name = "sentry-sdk" }, @@ -1865,7 +1871,8 @@ dependencies = [ { name = "distro" }, { name = "google-auth", extra = ["requests"] }, { name = "httpx" }, - { name = "pydantic" }, + { name = "pydantic", version = "2.11.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.13.*'" }, + { name = "pydantic", version = "2.13.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.13.*'" }, { name = "requests" }, { name = "sniffio" }, { name = "tenacity" }, @@ -1898,9 +1905,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8b/0f/a91f143f356523ff682309732b175765a9bc2836fd7c081c2c67fedc1ad4/greenlet-3.5.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:8f1cc966c126639cd152fdaa52624d2655f492faa79e013fea161de3e6dda082", size = 284726, upload-time = "2026-04-27T12:20:51.402Z" }, { url = "https://files.pythonhosted.org/packages/95/82/800646c7ffc5dbabd75ddd2f6b519bb898c0c9c969e5d0473bfe5d20bcce/greenlet-3.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:362624e6a8e5bca3b8233e45eef33903a100e9539a2b995c364d595dbc4018b3", size = 604264, upload-time = "2026-04-27T12:52:39.494Z" }, { url = "https://files.pythonhosted.org/packages/ca/ac/354867c0bba812fc33b15bc55aedafedd0aee3c7dd91dfca22444157dc0c/greenlet-3.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5ecd83806b0f4c2f53b1018e0005cd82269ea01d42befc0368730028d850ed1c", size = 616099, upload-time = "2026-04-27T12:59:39.623Z" }, - { url = "https://files.pythonhosted.org/packages/c9/ab/192090c4a5b30df148c22bf4b8895457d739a7c7c5a7b9c41e5dd7f537f2/greenlet-3.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa94cb2288681e3a11645958f1871d48ee9211bd2f66628fdace505927d6e564", size = 623976, upload-time = "2026-04-27T13:02:37.363Z" }, { url = "https://files.pythonhosted.org/packages/ff/b0/815bece7399e01cadb69014219eebd0042339875c59a59b0820a46ece356/greenlet-3.5.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ff251e9a0279522e62f6176412869395a64ddf2b5c5f782ff609a8216a4e662", size = 615198, upload-time = "2026-04-27T12:25:25.928Z" }, - { url = "https://files.pythonhosted.org/packages/24/11/05eb2b9b188c6df7d68a89c99134d644a7af616a40b9808e8e6ced315d5d/greenlet-3.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:64d6ac45f7271f48e45f67c95b54ef73534c52ec041fcda8edf520c6d811f4bc", size = 418379, upload-time = "2026-04-27T13:05:12.755Z" }, { url = "https://files.pythonhosted.org/packages/10/80/3b2c0a895d6698f6ddb31b07942ebfa982f3e30888bc5546a5b5990de8b2/greenlet-3.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d874e79afd41a96e11ff4c5d0bc90a80973e476fda1c2c64985667397df432b", size = 1574927, upload-time = "2026-04-27T12:53:25.81Z" }, { url = "https://files.pythonhosted.org/packages/44/0e/f354af514a4c61454dbc68e44d47544a5a4d6317e30b77ddfa3a09f4c5f3/greenlet-3.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0ed006e4b86c59de7467eb2601cd1b77b5a7d657d1ee55e30fe30d76451edba4", size = 1642683, upload-time = "2026-04-27T12:25:23.9Z" }, { url = "https://files.pythonhosted.org/packages/fa/6a/87f38255201e993a1915265ebb80cd7c2c78b04a45744995abbf6b259fd8/greenlet-3.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:703cb211b820dbffbbc55a16bfc6e4583a6e6e990f33a119d2cc8b83211119c8", size = 238115, upload-time = "2026-04-27T12:21:48.845Z" }, @@ -1908,9 +1913,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/32/f2ce6d4cac3e55bc6173f92dbe627e782e1850f89d986c3606feb63aafa7/greenlet-3.5.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:db2910d3c809444e0a20147361f343fe2798e106af8d9d8506f5305302655a9f", size = 286228, upload-time = "2026-04-27T12:20:34.421Z" }, { url = "https://files.pythonhosted.org/packages/b7/aa/caed9e5adf742315fc7be2a84196373aab4816e540e38ba0d76cb7584d68/greenlet-3.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ec9ea74e7268ace7f9aab1b1a4e730193fc661b39a993cd91c606c32d4a3628", size = 601775, upload-time = "2026-04-27T12:52:41.045Z" }, { url = "https://files.pythonhosted.org/packages/c7/af/90ae08497400a941595d12774447f752d3dfe0fbb012e35b76bc5c0ff37e/greenlet-3.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54d243512da35485fc7a6bf3c178fdda6327a9d6506fcdd62b1abd1e41b2927b", size = 614436, upload-time = "2026-04-27T12:59:41.595Z" }, - { url = "https://files.pythonhosted.org/packages/3f/e9/4eeadf8cb3403ac274245ba75f07844abc7fa5f6787583fc9156ba741e0f/greenlet-3.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:41353ec2ecedf7aa8f682753a41919f8718031a6edac46b8d3dc7ed9e1ceb136", size = 620610, upload-time = "2026-04-27T13:02:39.194Z" }, { url = "https://files.pythonhosted.org/packages/2b/e0/2e13df68f367e2f9960616927d60857dd7e56aaadd59a47c644216b2f920/greenlet-3.5.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d280a7f5c331622c69f97eb167f33577ff2d1df282c41cd15907fc0a3ca198c", size = 611388, upload-time = "2026-04-27T12:25:28.008Z" }, - { url = "https://files.pythonhosted.org/packages/ee/ef/f913b3c0eb7d26d86a2401c5e1546c9d46b657efee724b06f6f4ac5d8824/greenlet-3.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:58c1c374fe2b3d852f9b6b11a7dff4c85404e51b9a596fd9e89cf904eb09866d", size = 422775, upload-time = "2026-04-27T13:05:14.261Z" }, { url = "https://files.pythonhosted.org/packages/82/f7/393c64055132ac0d488ef6be549253b7e6274194863967ddc0bc8f5b87b8/greenlet-3.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1eb67d5adefb5bd2e182d42678a328979a209e4e82eb93575708185d31d1f588", size = 1570768, upload-time = "2026-04-27T12:53:28.099Z" }, { url = "https://files.pythonhosted.org/packages/b8/4b/eaf7735253522cf56d1b74d672a58f54fc114702ceaf05def59aae72f6e1/greenlet-3.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2628d6c86f6cb0cb45e0c3c54058bbec559f57eaae699447748cb3928150577e", size = 1635983, upload-time = "2026-04-27T12:25:26.903Z" }, { url = "https://files.pythonhosted.org/packages/4c/fe/4fb3a0805bd5165da5ebf858da7cc01cce8061674106d2cf5bdab32cbfde/greenlet-3.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:d4d9f0624c775f2dfc56ba54d515a8c771044346852a918b405914f6b19d7fd8", size = 238840, upload-time = "2026-04-27T12:23:54.806Z" }, @@ -1918,9 +1921,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/58/fc576f99037ce19c5aa16628e4c3226b6d1419f72a62c79f5f40576e6eb3/greenlet-3.5.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:5a5ed18de6a0f6cc7087f1563f6bd93fc7df1c19165ca01e9bde5a5dc281d106", size = 285066, upload-time = "2026-04-27T12:23:05.033Z" }, { url = "https://files.pythonhosted.org/packages/4a/ba/b28ddbe6bfad6a8ac196ef0e8cff37bc65b79735995b9e410923fffeeb70/greenlet-3.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a717fbc46d8a354fa675f7c1e813485b6ba3885f9bef0cd56e5ba27d758ff5b", size = 604414, upload-time = "2026-04-27T12:52:42.358Z" }, { url = "https://files.pythonhosted.org/packages/09/06/4b69f8f0b67603a8be2790e55107a190b376f2627fe0eaf5695d85ffb3cd/greenlet-3.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ddc090c5c1792b10246a78e8c2163ebbe04cf877f9d785c230a7b27b39ad038e", size = 617349, upload-time = "2026-04-27T12:59:43.32Z" }, - { url = "https://files.pythonhosted.org/packages/6a/15/a643b4ecd09969e30b8a150d5919960caae0abe4f5af75ab040b1ab85e78/greenlet-3.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4964101b8585c144cbda5532b1aa644255126c08a265dae90c16e7a0e63aaa9d", size = 623234, upload-time = "2026-04-27T13:02:40.611Z" }, { url = "https://files.pythonhosted.org/packages/8a/17/a3918541fd0ddefe024a69de6d16aa7b46d36ac19562adaa63c7fa180eff/greenlet-3.5.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2094acd54b272cb6eae8c03dd87b3fa1820a4cef18d6889c378d503500a1dc13", size = 613927, upload-time = "2026-04-27T12:25:30.28Z" }, - { url = "https://files.pythonhosted.org/packages/77/18/3b13d5ef1275b0ffaf933b05efa21408ac4ca95823c7411d79682e4fdcff/greenlet-3.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:7022615368890680e67b9965d33f5773aade330d5343bbe25560135aaa849eae", size = 425243, upload-time = "2026-04-27T13:05:15.689Z" }, { url = "https://files.pythonhosted.org/packages/ee/e1/bd0af6213c7dd33175d8a462d4c1fe1175124ebed4855bc1475a5b5242c2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5e05ba267789ea87b5a155cf0e810b1ab88bf18e9e8740813945ceb8ee4350ba", size = 1570893, upload-time = "2026-04-27T12:53:29.483Z" }, { url = "https://files.pythonhosted.org/packages/9b/2a/0789702f864f5382cb476b93d7a9c823c10472658102ccd65f415747d2e2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0ecec963079cd58cbd14723582384f11f166fd58883c15dcbfb342e0bc9b5846", size = 1636060, upload-time = "2026-04-27T12:25:28.845Z" }, { url = "https://files.pythonhosted.org/packages/b2/8f/22bf9df92bbff0eb07842b60f7e63bf7675a9742df628437a9f02d09137f/greenlet-3.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:728d9667d8f2f586644b748dbd9bb67e50d6a9381767d1357714ea6825bb3bf5", size = 238740, upload-time = "2026-04-27T12:24:01.341Z" }, @@ -1928,9 +1929,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/5e/a70f31e3e8d961c4ce589c15b28e4225d63704e431a23932a3808cbcc867/greenlet-3.5.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:f35807464c4c58c55f0d31dfa83c541a5615d825c2fe3d2b95360cf7c4e3c0a8", size = 285564, upload-time = "2026-04-27T12:23:08.555Z" }, { url = "https://files.pythonhosted.org/packages/af/a6/046c0a28e21833e4086918218cfb3d8bed51c075a1b700f20b9d7861c0f4/greenlet-3.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55fa7ea52771be44af0de27d8b80c02cd18c2c3cddde6c847ecebdf72418b6a1", size = 651166, upload-time = "2026-04-27T12:52:43.644Z" }, { url = "https://files.pythonhosted.org/packages/47/f8/4af27f71c5ff32a7fbc516adb46370d9c4ae2bc7bd3dc7d066ac542b4b15/greenlet-3.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a97e4821aa710603f94de0da25f25096454d78ffdace5dc77f3a006bc01abba3", size = 663792, upload-time = "2026-04-27T12:59:44.93Z" }, - { url = "https://files.pythonhosted.org/packages/fb/89/2dadb89793c37ee8b4c237857188293e9060dc085f19845c292e00f8e091/greenlet-3.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bf2d8a80bec89ab46221ae45c5373d5ba0bd36c19aa8508e85c6cd7e5106cd37", size = 668086, upload-time = "2026-04-27T13:02:42.314Z" }, { url = "https://files.pythonhosted.org/packages/a3/59/1bd6d7428d6ed9106efbb8c52310c60fd04f6672490f452aeaa3829aa436/greenlet-3.5.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f52a464e4ed91780bdfbbdd2b97197f3accaa629b98c200f4dffada759f3ae7", size = 660933, upload-time = "2026-04-27T12:25:33.276Z" }, - { url = "https://files.pythonhosted.org/packages/82/35/75722be7e26a2af4cbd2dc35b0ed382dacf9394b7e75551f76ed1abe87f2/greenlet-3.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:1bae92a1dd94c5f9d9493c3a212dd874c202442047cf96446412c862feca83a2", size = 470799, upload-time = "2026-04-27T13:05:17.094Z" }, { url = "https://files.pythonhosted.org/packages/83/e4/b903e5a5fae1e8a28cdd32a0cfbfd560b668c25b692f67768822ddc5f40f/greenlet-3.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:762612baf1161ccb8437c0161c668a688223cba28e1bf038f4eb47b13e39ccdf", size = 1618401, upload-time = "2026-04-27T12:53:31.062Z" }, { url = "https://files.pythonhosted.org/packages/0e/e3/5ec408a329acb854fb607a122e1ee5fb3ff649f9a97952948a90803c0d8e/greenlet-3.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:57a43c6079a89713522bc4bcb9f75070ecf5d3dbad7792bfe42239362cbf2a16", size = 1682038, upload-time = "2026-04-27T12:25:31.838Z" }, { url = "https://files.pythonhosted.org/packages/91/20/6b165108058767ee643c55c5c4904d591a830ee2b3c7dbd359828fbc829f/greenlet-3.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033", size = 239835, upload-time = "2026-04-27T12:24:54.136Z" }, @@ -1938,9 +1937,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/a8/4522939255bb5409af4e87132f915446bf3622c2c292d14d3c38d128ae82/greenlet-3.5.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:a10a732421ab4fec934783ce3e54763470d0181db6e3468f9103a275c3ed1853", size = 293614, upload-time = "2026-04-27T12:24:12.874Z" }, { url = "https://files.pythonhosted.org/packages/15/5e/8744c52e2c027b5a8772a01561934c8835f869733e101f62075c60430340/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fc391b1566f2907d17aaebe78f8855dc45675159a775fcf9e61f8ee0078e87f", size = 650723, upload-time = "2026-04-27T12:52:45.412Z" }, { url = "https://files.pythonhosted.org/packages/00/ef/7b4c39c03cf46ceca512c5d3f914afd85aa30b2cc9a93015b0dd73e4be6c/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:680bd0e7ad5e8daa8a4aa89f68fd6adc834b8a8036dc256533f7e08f4a4b01f7", size = 656529, upload-time = "2026-04-27T12:59:46.295Z" }, - { url = "https://files.pythonhosted.org/packages/5f/5c/0602239503b124b70e39355cbdb39361ecfe65b87a5f2f63752c32f5286f/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1aa4ce8debcd4ea7fb2e150f3036588c41493d1d52c43538924ae1819003f4ce", size = 657015, upload-time = "2026-04-27T13:02:43.973Z" }, { url = "https://files.pythonhosted.org/packages/0b/b5/c7768f352f5c010f92064d0063f987e7dc0cd290a6d92a34109015ce4aa1/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddb36c7d6c9c0a65f18c7258634e0c416c6ab59caac8c987b96f80c2ebda0112", size = 654364, upload-time = "2026-04-27T12:25:35.64Z" }, - { url = "https://files.pythonhosted.org/packages/38/51/8699f865f125dc952384cb432b0f7138aa4d8f2969a7d12d0df5b94d054d/greenlet-3.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:728a73687e39ae9ca34e4694cbf2f049d3fbc7174639468d0f67200a97d8f9e2", size = 488275, upload-time = "2026-04-27T13:05:18.28Z" }, { url = "https://files.pythonhosted.org/packages/ef/d0/079ebe12e4b1fc758857ce5be1a5e73f06870f2101e52611d1e71925ce54/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e5ddf316ced87539144621453c3aef229575825fe60c604e62bedc4003f372b2", size = 1614204, upload-time = "2026-04-27T12:53:32.618Z" }, { url = "https://files.pythonhosted.org/packages/6d/89/6c2fb63df3596552d20e58fb4d96669243388cf680cff222758812c7bfaa/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4a448128607be0de65342dc9b31be7f948ef4cc0bc8832069350abefd310a8f2", size = 1675480, upload-time = "2026-04-27T12:25:34.168Z" }, { url = "https://files.pythonhosted.org/packages/15/32/77ee8a6c1564fc345a491a4e85b3bf360e4cf26eac98c4532d2fdb96e01f/greenlet-3.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d60097128cb0a1cab9ea541186ea13cd7b847b8449a7787c2e2350da0cb82d86", size = 245324, upload-time = "2026-04-27T12:24:40.295Z" }, @@ -1954,7 +1951,8 @@ dependencies = [ { name = "anyio" }, { name = "distro" }, { name = "httpx" }, - { name = "pydantic" }, + { name = "pydantic", version = "2.11.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.13.*'" }, + { name = "pydantic", version = "2.13.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.13.*'" }, { name = "sniffio" }, { name = "typing-extensions" }, ] @@ -2259,8 +2257,10 @@ dependencies = [ { name = "eval-type-backport" }, { name = "exceptiongroup" }, { name = "httpx" }, - { name = "pydantic" }, - { name = "pydantic-core" }, + { name = "pydantic", version = "2.11.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.13.*'" }, + { name = "pydantic", version = "2.13.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.13.*'" }, + { name = "pydantic-core", version = "2.33.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.13.*'" }, + { name = "pydantic-core", version = "2.46.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.13.*'" }, { name = "typing-extensions" }, { name = "websockets" }, ] @@ -2738,7 +2738,8 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain-core" }, { name = "langgraph" }, - { name = "pydantic" }, + { name = "pydantic", version = "2.11.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.13.*'" }, + { name = "pydantic", version = "2.13.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.13.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a6/74/03fd4c07993c49c4b80635bb4c723643ff78af81c9471d1266f879f68df1/langchain-1.3.0.tar.gz", hash = "sha256:8ec70ee0cef94255f3e522423b254093a3dd34509638d353c50f3d9dd498debc", size = 580604, upload-time = "2026-05-12T14:45:50.7Z" } wheels = [ @@ -2753,7 +2754,8 @@ dependencies = [ { name = "langchain-core" }, { name = "langchain-text-splitters" }, { name = "langsmith" }, - { name = "pydantic" }, + { name = "pydantic", version = "2.11.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.13.*'" }, + { name = "pydantic", version = "2.13.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.13.*'" }, { name = "pyyaml" }, { name = "requests" }, { name = "sqlalchemy" }, @@ -2795,7 +2797,8 @@ dependencies = [ { name = "langchain-protocol" }, { name = "langsmith" }, { name = "packaging" }, - { name = "pydantic" }, + { name = "pydantic", version = "2.11.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.13.*'" }, + { name = "pydantic", version = "2.13.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.13.*'" }, { name = "pyyaml" }, { name = "tenacity" }, { name = "typing-extensions" }, @@ -2853,7 +2856,8 @@ dependencies = [ { name = "langgraph-checkpoint" }, { name = "langgraph-prebuilt" }, { name = "langgraph-sdk" }, - { name = "pydantic" }, + { name = "pydantic", version = "2.11.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.13.*'" }, + { name = "pydantic", version = "2.13.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.13.*'" }, { name = "xxhash" }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/61/d5d25e783035aa307d289b37e082258a6061c0fb4caa4a284f3bf1e87169/langgraph-1.2.0.tar.gz", hash = "sha256:4a9baaf62afc5d5f63144a50095140a34b9aa9b7cea695d25326d564775348e7", size = 690248, upload-time = "2026-05-12T03:46:39.164Z" } @@ -2908,7 +2912,8 @@ dependencies = [ { name = "httpx" }, { name = "orjson", marker = "platform_python_implementation != 'PyPy'" }, { name = "packaging" }, - { name = "pydantic" }, + { name = "pydantic", version = "2.11.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.13.*'" }, + { name = "pydantic", version = "2.13.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.13.*'" }, { name = "requests" }, { name = "requests-toolbelt" }, { name = "uuid-utils" }, @@ -3198,7 +3203,8 @@ dependencies = [ { name = "httpx" }, { name = "httpx-sse" }, { name = "jsonschema" }, - { name = "pydantic" }, + { name = "pydantic", version = "2.11.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.13.*'" }, + { name = "pydantic", version = "2.13.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.13.*'" }, { name = "pydantic-settings" }, { name = "pyjwt", extra = ["crypto"] }, { name = "python-multipart" }, @@ -3237,7 +3243,8 @@ dependencies = [ { name = "openai" }, { name = "posthog" }, { name = "protobuf" }, - { name = "pydantic" }, + { name = "pydantic", version = "2.11.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.13.*'" }, + { name = "pydantic", version = "2.13.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.13.*'" }, { name = "pytz" }, { name = "qdrant-client" }, { name = "sqlalchemy" }, @@ -3257,7 +3264,8 @@ dependencies = [ { name = "jsonpath-python" }, { name = "opentelemetry-api" }, { name = "opentelemetry-semantic-conventions" }, - { name = "pydantic" }, + { name = "pydantic", version = "2.11.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.13.*'" }, + { name = "pydantic", version = "2.13.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.13.*'" }, { name = "python-dateutil" }, { name = "typing-inspection" }, ] @@ -3828,7 +3836,8 @@ dependencies = [ { name = "distro" }, { name = "httpx" }, { name = "jiter" }, - { name = "pydantic" }, + { name = "pydantic", version = "2.11.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.13.*'" }, + { name = "pydantic", version = "2.13.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.13.*'" }, { name = "sniffio" }, { name = "tqdm" }, { name = "typing-extensions" }, @@ -4205,7 +4214,8 @@ dependencies = [ { name = "openai" }, { name = "pillow" }, { name = "protobuf" }, - { name = "pydantic" }, + { name = "pydantic", version = "2.11.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.13.*'" }, + { name = "pydantic", version = "2.13.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.13.*'" }, { name = "pyloudnorm" }, { name = "resampy" }, { name = "soxr" }, @@ -4404,6 +4414,9 @@ tracing = [ ultravox = [ { name = "websockets" }, ] +vonage-video-connector = [ + { name = "vonage-video-connector", marker = "python_full_version == '3.13.*' and sys_platform == 'linux'" }, +] webrtc = [ { name = "aiortc" }, { name = "opencv-python" }, @@ -4557,10 +4570,11 @@ requires-dist = [ { name = "transformers", marker = "extra == 'local-smart-turn'", specifier = ">=4.48.0,<6" }, { name = "transformers", marker = "extra == 'moondream'", specifier = ">=4.48.0,<6" }, { name = "uvicorn", marker = "extra == 'runner'", specifier = ">=0.32.0,<1.0.0" }, + { name = "vonage-video-connector", marker = "python_full_version == '3.13.*' and sys_platform == 'linux' and extra == 'vonage-video-connector'", specifier = "~=0.2.3b0" }, { name = "wait-for2", marker = "python_full_version < '3.12'", specifier = ">=0.4.1,<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", "langchain", "lemonslice", "livekit", "lmnt", "local", "local-smart-turn", "mcp", "mem0", "mistral", "mlx-whisper", "moondream", "nebius", "neuphonic", "novita", "nvidia", "openai", "rnnoise", "openrouter", "perplexity", "piper", "qwen", "resembleai", "rime", "runner", "sagemaker", "sambanova", "sarvam", "sentry", "silero", "simli", "smallest", "soniox", "soundfile", "speechmatics", "strands", "tavus", "together", "tracing", "ultravox", "webrtc", "websocket", "websockets-base", "whisper", "xai"] +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", "langchain", "lemonslice", "livekit", "lmnt", "local", "local-smart-turn", "mcp", "mem0", "mistral", "mlx-whisper", "moondream", "nebius", "neuphonic", "novita", "nvidia", "openai", "rnnoise", "openrouter", "perplexity", "piper", "qwen", "resembleai", "rime", "runner", "sagemaker", "sambanova", "sarvam", "sentry", "silero", "simli", "smallest", "soniox", "soundfile", "speechmatics", "strands", "tavus", "together", "tracing", "ultravox", "vonage-video-connector", "webrtc", "websocket", "websockets-base", "whisper", "xai"] [package.metadata.requires-dev] dev = [ @@ -4930,15 +4944,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] +[[package]] +name = "pydantic" +version = "2.11.10" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.13.*'", +] +dependencies = [ + { name = "annotated-types", marker = "python_full_version == '3.13.*'" }, + { name = "pydantic-core", version = "2.33.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.13.*'" }, + { name = "typing-extensions", marker = "python_full_version == '3.13.*'" }, + { name = "typing-inspection", marker = "python_full_version == '3.13.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/54/ecab642b3bed45f7d5f59b38443dcb36ef50f85af192e6ece103dbfe9587/pydantic-2.11.10.tar.gz", hash = "sha256:dc280f0982fbda6c38fada4e476dc0a4f3aeaf9c6ad4c28df68a666ec3c61423", size = 788494, upload-time = "2025-10-04T10:40:41.338Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/1f/73c53fcbfb0b5a78f91176df41945ca466e71e9d9d836e5c522abda39ee7/pydantic-2.11.10-py3-none-any.whl", hash = "sha256:802a655709d49bd004c31e865ef37da30b540786a46bfce02333e0e24b5fe29a", size = 444823, upload-time = "2025-10-04T10:40:39.055Z" }, +] + +[package.optional-dependencies] +email = [ + { name = "email-validator", marker = "python_full_version == '3.13.*'" }, +] + [[package]] name = "pydantic" version = "2.13.4" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.12.*'", + "python_full_version < '3.12'", +] dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, + { name = "annotated-types", marker = "python_full_version != '3.13.*'" }, + { name = "pydantic-core", version = "2.46.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.13.*'" }, + { name = "typing-extensions", marker = "python_full_version != '3.13.*'" }, + { name = "typing-inspection", marker = "python_full_version != '3.13.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } wheels = [ @@ -4947,15 +4989,88 @@ wheels = [ [package.optional-dependencies] email = [ - { name = "email-validator" }, + { name = "email-validator", marker = "python_full_version != '3.13.*'" }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.13.*'", +] +dependencies = [ + { name = "typing-extensions", marker = "python_full_version == '3.13.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/8d/71db63483d518cbbf290261a1fc2839d17ff89fce7089e08cad07ccfce67/pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", size = 2028584, upload-time = "2025-04-23T18:31:03.106Z" }, + { url = "https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", size = 1855071, upload-time = "2025-04-23T18:31:04.621Z" }, + { url = "https://files.pythonhosted.org/packages/b3/d3/4ae42d33f5e3f50dd467761304be2fa0a9417fbf09735bc2cce003480f2a/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", size = 1897823, upload-time = "2025-04-23T18:31:06.377Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f3/aa5976e8352b7695ff808599794b1fba2a9ae2ee954a3426855935799488/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", size = 1983792, upload-time = "2025-04-23T18:31:07.93Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7a/cda9b5a23c552037717f2b2a5257e9b2bfe45e687386df9591eff7b46d28/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", size = 2136338, upload-time = "2025-04-23T18:31:09.283Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/b8f9ec8dd1417eb9da784e91e1667d58a2a4a7b7b34cf4af765ef663a7e5/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", size = 2730998, upload-time = "2025-04-23T18:31:11.7Z" }, + { url = "https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", size = 2003200, upload-time = "2025-04-23T18:31:13.536Z" }, + { url = "https://files.pythonhosted.org/packages/ca/22/3602b895ee2cd29d11a2b349372446ae9727c32e78a94b3d588a40fdf187/pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", size = 2113890, upload-time = "2025-04-23T18:31:15.011Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e6/e3c5908c03cf00d629eb38393a98fccc38ee0ce8ecce32f69fc7d7b558a7/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d", size = 2073359, upload-time = "2025-04-23T18:31:16.393Z" }, + { url = "https://files.pythonhosted.org/packages/12/e7/6a36a07c59ebefc8777d1ffdaf5ae71b06b21952582e4b07eba88a421c79/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", size = 2245883, upload-time = "2025-04-23T18:31:17.892Z" }, + { url = "https://files.pythonhosted.org/packages/16/3f/59b3187aaa6cc0c1e6616e8045b284de2b6a87b027cce2ffcea073adf1d2/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", size = 2241074, upload-time = "2025-04-23T18:31:19.205Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ed/55532bb88f674d5d8f67ab121a2a13c385df382de2a1677f30ad385f7438/pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", size = 1910538, upload-time = "2025-04-23T18:31:20.541Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", size = 1952909, upload-time = "2025-04-23T18:31:22.371Z" }, + { url = "https://files.pythonhosted.org/packages/49/a9/d809358e49126438055884c4366a1f6227f0f84f635a9014e2deb9b9de54/pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", size = 1897786, upload-time = "2025-04-23T18:31:24.161Z" }, + { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" }, + { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" }, + { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" }, + { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" }, + { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" }, + { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" }, + { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" }, + { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" }, + { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" }, + { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, + { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, + { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, + { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, + { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, + { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, + { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, + { url = "https://files.pythonhosted.org/packages/7b/27/d4ae6487d73948d6f20dddcd94be4ea43e74349b56eba82e9bdee2d7494c/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", size = 2025200, upload-time = "2025-04-23T18:33:14.199Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b8/b3cb95375f05d33801024079b9392a5ab45267a63400bf1866e7ce0f0de4/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", size = 1859123, upload-time = "2025-04-23T18:33:16.555Z" }, + { url = "https://files.pythonhosted.org/packages/05/bc/0d0b5adeda59a261cd30a1235a445bf55c7e46ae44aea28f7bd6ed46e091/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", size = 1892852, upload-time = "2025-04-23T18:33:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/d37bdebbda2e449cb3f519f6ce950927b56d62f0b84fd9cb9e372a26a3d5/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", size = 2067484, upload-time = "2025-04-23T18:33:20.475Z" }, + { url = "https://files.pythonhosted.org/packages/8c/55/1f95f0a05ce72ecb02a8a8a1c3be0579bbc29b1d5ab68f1378b7bebc5057/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", size = 2108896, upload-time = "2025-04-23T18:33:22.501Z" }, + { url = "https://files.pythonhosted.org/packages/53/89/2b2de6c81fa131f423246a9109d7b2a375e83968ad0800d6e57d0574629b/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", size = 2069475, upload-time = "2025-04-23T18:33:24.528Z" }, + { url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013, upload-time = "2025-04-23T18:33:26.621Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715, upload-time = "2025-04-23T18:33:28.656Z" }, + { url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" }, ] [[package]] name = "pydantic-core" version = "2.46.4" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.12.*'", + "python_full_version < '3.12'", +] dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version != '3.13.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } wheels = [ @@ -5057,7 +5172,8 @@ name = "pydantic-extra-types" version = "2.11.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pydantic" }, + { name = "pydantic", version = "2.11.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.13.*'" }, + { name = "pydantic", version = "2.13.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.13.*'" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/71/dba38ee2651f84f7842206adbd2233d8bbdb59fb85e9fa14232486a8c471/pydantic_extra_types-2.11.1.tar.gz", hash = "sha256:46792d2307383859e923d8fcefa82108b1a141f8a9c0198982b3832ab5ef1049", size = 172002, upload-time = "2026-03-16T08:08:03.92Z" } @@ -5070,7 +5186,8 @@ name = "pydantic-settings" version = "2.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pydantic" }, + { name = "pydantic", version = "2.11.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.13.*'" }, + { name = "pydantic", version = "2.13.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.13.*'" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] @@ -5434,7 +5551,8 @@ dependencies = [ { name = "numpy" }, { name = "portalocker" }, { name = "protobuf" }, - { name = "pydantic" }, + { name = "pydantic", version = "2.11.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.13.*'" }, + { name = "pydantic", version = "2.13.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.13.*'" }, { name = "urllib3" }, ] sdist = { url = "https://files.pythonhosted.org/packages/65/45/5b1bdd15a3c7730eefb9c113600829e20d689b82b5a23f9e07d107094004/qdrant_client-1.18.0.tar.gz", hash = "sha256:52e8ece1a7d40519801bf0b70713bfa0f6b7ae28c7275bbe0b0286fbed7f6db4", size = 352580, upload-time = "2026-05-11T14:12:38.702Z" } @@ -5925,8 +6043,10 @@ version = "0.1.28" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, - { name = "pydantic" }, - { name = "pydantic-core" }, + { name = "pydantic", version = "2.11.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.13.*'" }, + { name = "pydantic", version = "2.13.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.13.*'" }, + { name = "pydantic-core", version = "2.33.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.13.*'" }, + { name = "pydantic-core", version = "2.46.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.13.*'" }, { name = "typing-extensions" }, { name = "websockets" }, ] @@ -6254,7 +6374,8 @@ version = "0.2.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, - { name = "pydantic" }, + { name = "pydantic", version = "2.11.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.13.*'" }, + { name = "pydantic", version = "2.13.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.13.*'" }, { name = "speechmatics-rt" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e4/b2/72b5b2203bbefbd22e7692adaca0dd7c2feebed1aaea5599ec579f74fbbf/speechmatics_voice-0.2.8.tar.gz", hash = "sha256:b2d9cbf773fd94400c744734662e2b16b5bdc4271d0dafde46ac032c438fe000", size = 61419, upload-time = "2026-01-26T16:26:09.082Z" } @@ -6554,7 +6675,8 @@ dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-instrumentation-threading" }, { name = "opentelemetry-sdk" }, - { name = "pydantic" }, + { name = "pydantic", version = "2.11.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.13.*'" }, + { name = "pydantic", version = "2.13.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.13.*'" }, { name = "pyyaml" }, { name = "typing-extensions" }, { name = "watchdog" }, @@ -7141,6 +7263,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-py3-none-any.whl", hash = "sha256:7d5987d8369e098e41406efb780a3d4ca79280097293899e351a6407ee153ab3", size = 7594554, upload-time = "2026-05-13T18:01:27.815Z" }, ] +[[package]] +name = "vonage-video-connector" +version = "0.2.3b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic", version = "2.11.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.13.*'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/db/385df7fd618b31f0def554aca568d87b4b2f9ccc3a1457ae7eea5e8bf775/vonage_video_connector-0.2.3b0-py3-none-manylinux_2_35_aarch64.whl", hash = "sha256:9d1ffa93f3aadd24a980294df2b63b0f853b8dfa25b277690e0864e7586f8bb7", size = 12101114, upload-time = "2026-03-02T15:34:45.007Z" }, + { url = "https://files.pythonhosted.org/packages/9f/4e/03b183599370473c3277140e9ecbb33621449935a02042ecbcf8c555ebad/vonage_video_connector-0.2.3b0-py3-none-manylinux_2_35_x86_64.whl", hash = "sha256:718e39e7e488ac50fecda75e24ab01c9d16d4078bb4f79ee7857e282493e2e4e", size = 13971535, upload-time = "2026-03-02T15:34:47.186Z" }, +] + [[package]] name = "wait-for2" version = "0.4.1"