diff --git a/changelog/+aic-sdk-021-vad.changed.md b/changelog/+aic-sdk-021-vad.changed.md new file mode 100644 index 00000000000..6e6ef0cfefa --- /dev/null +++ b/changelog/+aic-sdk-021-vad.changed.md @@ -0,0 +1 @@ +Updated the ai-coustics integration to SDK 0.21 (bumped `aic-sdk` to `~=2.5.0`). `AICQuailVADAnalyzer` now reports the model's continuous raw VAD probability (`VadContext.raw_vad_probability()`) gated by Pipecat's `VADParams` instead of a binary speech flag, and defaults to the new `quail-vf-vad-2.0-s-16khz` model. The ai-coustics voice examples now use the `quail-vf-2.2-l-16khz` enhancement model. diff --git a/changelog/+aic-tyto-analyzer.added.md b/changelog/+aic-tyto-analyzer.added.md new file mode 100644 index 00000000000..d6d5918dfb5 --- /dev/null +++ b/changelog/+aic-tyto-analyzer.added.md @@ -0,0 +1 @@ +Added `AICTytoAnalyzer`, a real-time audio-quality processor backed by the ai-coustics Tyto model (aic-sdk 2.4.0+). It taps the pipeline's input audio and periodically emits an `AICAudioQualityMetricsData` (seven `0.0`–`1.0` scores predicting downstream STT/VAD/turn-taking degradation) via a `MetricsFrame` and an `on_audio_analysis` event. The scores are also forwarded to RTVI clients under the `audio_quality` metrics key. diff --git a/examples/voice/voice-aicoustics-audio-quality.py b/examples/voice/voice-aicoustics-audio-quality.py new file mode 100644 index 00000000000..2a1991c5461 --- /dev/null +++ b/examples/voice/voice-aicoustics-audio-quality.py @@ -0,0 +1,90 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Minimal audio-quality test bot for AICTytoAnalyzer. + +Runs only the ai-coustics Tyto analysis model on the input audio so you can +watch its quality scores react to speech, silence, and background noise without +paying for STT/LLM/TTS API calls. The AICTytoAnalyzer is placed right after +``transport.input()`` so it scores the raw microphone signal (move it after an +AICFilter to score enhanced audio instead). + +Logging: + - INFO "audio quality" once per ``analysis_interval`` with the seven Tyto + scores. ``risk_score`` (and ``noise`` / ``interfering_speech``) rising + under poor conditions is the signal that the analyzer is working. + - DEBUG init lines from AICTytoAnalyzer (run with LOGURU_LEVEL=DEBUG). + +Required env vars: + AIC_SDK_LICENSE ai-coustics SDK license key + Plus whatever credentials the chosen transport needs (DAILY_*, etc.) + +Run: + LOGURU_LEVEL=DEBUG uv run python examples/voice/voice-aicoustics-audio-quality.py daily +""" + +import os + +from dotenv import load_dotenv +from loguru import logger + +from pipecat.metrics.metrics import AICAudioQualityMetricsData +from pipecat.pipeline.pipeline import Pipeline +from pipecat.pipeline.worker import PipelineParams, PipelineWorker +from pipecat.processors.audio.aic_tyto_analyzer import AICTytoAnalyzer +from pipecat.runner.types import RunnerArguments +from pipecat.runner.utils import create_transport +from pipecat.transports.base_transport import BaseTransport, TransportParams +from pipecat.transports.daily.transport import DailyParams +from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams +from pipecat.workers.runner import WorkerRunner + +load_dotenv(override=True) + + +aic_tyto_analyzer = AICTytoAnalyzer( + license_key=os.environ["AIC_SDK_LICENSE"], + analysis_interval=1.0, +) + + +@aic_tyto_analyzer.event_handler("on_audio_analysis") +async def on_audio_analysis(_processor, scores: AICAudioQualityMetricsData) -> None: + logger.info( + "audio quality: " + f"risk={scores.risk_score:.2f} noise={scores.noise:.2f} " + f"interfering_speech={scores.interfering_speech:.2f} " + f"media_speech={scores.media_speech:.2f} reverb={scores.speaker_reverb:.2f} " + f"loudness={scores.speaker_loudness:.2f} packet_loss={scores.packet_loss:.2f}" + ) + + +transport_params = { + "daily": lambda: DailyParams(audio_in_enabled=True), + "twilio": lambda: FastAPIWebsocketParams(audio_in_enabled=True), + "webrtc": lambda: TransportParams(audio_in_enabled=True), +} + + +async def run_bot(transport: BaseTransport, runner_args: RunnerArguments) -> None: + logger.info("Audio-quality test bot starting") + pipeline = Pipeline([transport.input(), aic_tyto_analyzer]) + worker = PipelineWorker(pipeline, params=PipelineParams(enable_metrics=True)) + runner = WorkerRunner(handle_sigint=runner_args.handle_sigint) + await runner.add_workers(worker) + await runner.run() + + +async def bot(runner_args: RunnerArguments) -> None: + """Main bot entry point compatible with Pipecat Cloud.""" + transport = await create_transport(runner_args, transport_params) + await run_bot(transport, runner_args) + + +if __name__ == "__main__": + from pipecat.runner.run import main + + main() diff --git a/examples/voice/voice-aicoustics-vad-only.py b/examples/voice/voice-aicoustics-vad-only.py index f4233d1fcf7..9c77a54ce63 100644 --- a/examples/voice/voice-aicoustics-vad-only.py +++ b/examples/voice/voice-aicoustics-vad-only.py @@ -144,7 +144,7 @@ async def process_frame(self, frame: Frame, direction: FrameDirection) -> None: aic_filter = AICFilter( license_key=os.environ["AIC_SDK_LICENSE"], - model_id="quail-vf-2.1-l-16khz", + model_id="quail-vf-2.2-l-16khz", enhancement_level=0.8, ) aic_vad_analyzer = LoggingAICQuailVADAnalyzer( diff --git a/examples/voice/voice-aicoustics.py b/examples/voice/voice-aicoustics.py index dfdbe216e31..bf955345196 100644 --- a/examples/voice/voice-aicoustics.py +++ b/examples/voice/voice-aicoustics.py @@ -42,7 +42,7 @@ def _create_aic_filter() -> AICFilter: return AICFilter( license_key=license_key, - model_id="quail-vf-2.1-l-16khz", + model_id="quail-vf-2.2-l-16khz", enhancement_level=0.8, ) diff --git a/pyproject.toml b/pyproject.toml index d847e928fe2..6cd3ef93e20 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,7 +62,7 @@ pipecat = "pipecat.cli.main:run" pc = "pipecat.cli.main:run" [project.optional-dependencies] -aic = [ "aic-sdk~=2.3.0" ] +aic = [ "aic-sdk~=2.5.0" ] anthropic = [ "anthropic>=0.49.0,<1" ] assemblyai = [] asyncai = [] diff --git a/src/pipecat/audio/vad/aic_quail_vad.py b/src/pipecat/audio/vad/aic_quail_vad.py index 75dbfdf0ad7..d61d5b737e9 100644 --- a/src/pipecat/audio/vad/aic_quail_vad.py +++ b/src/pipecat/audio/vad/aic_quail_vad.py @@ -4,16 +4,17 @@ # SPDX-License-Identifier: BSD 2-Clause License # -"""Standalone Quail VAD 2.0 analyzer for Pipecat. +"""Standalone Quail VAD analyzer for Pipecat. -Runs the standalone Quail VAD 2.0 model from the ai-coustics SDK as a dedicated -VAD-only processor. Unlike :class:`pipecat.audio.vad.aic_vad.AICVADAnalyzer`, -which queries the model-internal VAD of :class:`pipecat.audio.filters.aic_filter.AICFilter`, -this analyzer owns its own :class:`aic_sdk.Processor` instance and can be placed +Runs a standalone Quail VAD-only model from the ai-coustics SDK (e.g. Quail VAD +2.0 or VF VAD 2.0) as a dedicated VAD processor. Unlike +:class:`pipecat.audio.vad.aic_vad.AICVADAnalyzer`, which queries the +model-internal VAD of :class:`pipecat.audio.filters.aic_filter.AICFilter`, this +analyzer owns its own :class:`aic_sdk.Processor` instance and can be placed anywhere in the pipeline. Classes: - AICQuailVADAnalyzer: Standalone Quail VAD 2.0 analyzer. + AICQuailVADAnalyzer: Standalone Quail VAD analyzer. """ from __future__ import annotations @@ -26,7 +27,6 @@ Model, Processor, ProcessorConfig, - VadParameter, set_sdk_id, ) from loguru import logger @@ -36,7 +36,7 @@ if TYPE_CHECKING: from aic_sdk import VadContext -DEFAULT_QUAIL_VAD_MODEL_ID = "quail-vad-2.0-xxs-16khz" +DEFAULT_QUAIL_VAD_MODEL_ID = "quail-vf-vad-2.0-s-16khz" # Telemetry identifier registered with the AIC SDK; identifies pipecat to the # vendor's usage pipeline. Mirrors the value used by AICFilter; kept private @@ -53,35 +53,27 @@ class AICQuailVADAnalyzer(VADAnalyzer): The analyzer owns a dedicated :class:`aic_sdk.Processor` initialized with a Quail VAD-only model. Each :meth:`voice_confidence` call processes one audio - window through the processor and queries the resulting - :class:`aic_sdk.VadContext` for the speech-detected boolean, which is mapped - to ``1.0`` / ``0.0`` to satisfy the :class:`VADAnalyzer` interface. + window through the processor and returns the model's raw speech probability + in ``[0.0, 1.0]`` (:meth:`aic_sdk.VadContext.raw_vad_probability`). The base + :class:`VADAnalyzer` state machine then gates speech start/stop using its own + :class:`VADParams` (``confidence`` threshold, ``start_secs``, ``stop_secs``), + so the SDK's own VAD post-processing (sensitivity thresholding, speech-hold) + is intentionally bypassed — Pipecat owns the thresholding. Comparison to :class:`pipecat.audio.vad.aic_vad.AICVADAnalyzer` (deprecated): - - **Model:** Quail VAD-only model (e.g. ``quail-vad-2.0-xxs-16khz``); the + - **Model:** Quail VAD-only model (e.g. ``quail-vf-vad-2.0-s-16khz``); the deprecated analyzer uses the enhancement model's internal VAD as a side-channel. - **Audio path:** runs on whatever the pipeline feeds it (raw or enhanced). The deprecated analyzer reads post-enhancement VAD state from :class:`AICFilter`'s processor. - - **Sensitivity semantics:** speech-probability threshold in ``[0.0, 1.0]`` - on dedicated VAD models. The deprecated analyzer's enhancement-model VAD - uses an energy threshold in ``[1.0, 15.0]``. + - **Confidence:** a continuous raw probability gated by Pipecat's + ``VADParams.confidence``. The deprecated analyzer exposes only a boolean + gated by the enhancement model's energy threshold (``[1.0, 15.0]``). - **Coupling:** independent — owns its own ``Processor``. The deprecated analyzer is bound to an :class:`AICFilter` instance. - Quail VAD parameters (applied via :class:`aic_sdk.VadParameter`): - - - **speech_hold_duration**: seconds the VAD continues reporting speech after - the signal stops containing speech. Range 0.0 to 300x the model window - length. Default 0.03s. - - **minimum_speech_duration**: seconds of speech required before the VAD - reports speech detected. Range 0.0 to 1.0. Default 0.0s. - - **sensitivity**: speech-probability threshold on dedicated Quail VAD - models (range 0.0 to 1.0). Energy-based VADs keep the 1.0 to 15.0 range. - Default is model-specific. - Example:: analyzer = AICQuailVADAnalyzer(license_key=os.environ["AIC_SDK_LICENSE"]) @@ -96,9 +88,6 @@ def __init__( model_id: str | None = DEFAULT_QUAIL_VAD_MODEL_ID, model_path: Path | None = None, model_download_dir: Path | None = None, - speech_hold_duration: float | None = None, - minimum_speech_duration: float | None = None, - sensitivity: float | None = None, sample_rate: int | None = None, params: VADParams | None = None, ) -> None: @@ -111,21 +100,13 @@ def __init__( Args: license_key: ai-coustics SDK license key. model_id: Quail VAD model identifier. Defaults to the published - standalone VAD model ``"quail-vad-2.0-xxs-16khz"``. See + standalone VAD model ``"quail-vf-vad-2.0-s-16khz"``. See https://artifacts.ai-coustics.io/ for the catalogue. Ignored if ``model_path`` is provided. model_path: Optional path to a local ``.aicmodel`` file. Overrides ``model_id`` when set. model_download_dir: Directory for downloaded models. Defaults to ``~/.cache/pipecat/aic-models``. - speech_hold_duration: Optional override for the SDK's - ``VadParameter.SpeechHoldDuration``. - minimum_speech_duration: Optional override for the SDK's - ``VadParameter.MinimumSpeechDuration``. - sensitivity: Optional override for the SDK's - ``VadParameter.Sensitivity``. This is a probability threshold - in ``[0.0, 1.0]``. Values above this threshold are considered - speech. sample_rate: Initial sample rate; the pipeline will set this via :meth:`set_sample_rate` once the transport rate is known. params: Optional :class:`VADParams` for the base state machine. @@ -148,10 +129,6 @@ def __init__( Path.home() / ".cache" / "pipecat" / "aic-models" ) - self._pending_speech_hold_duration = speech_hold_duration - self._pending_minimum_speech_duration = minimum_speech_duration - self._pending_sensitivity = sensitivity - self._model: Model | None = None self._processor: Processor | None = None self._vad_ctx: VadContext | None = None @@ -235,28 +212,8 @@ def _initialize_processor(self, sample_rate: int) -> None: self._in_f32 = np.zeros((1, num_frames), dtype=np.float32) self._inference_error_logged = False self._buffer_size_warning_logged = False - self._apply_vad_parameters() logger.debug(f"AICQuailVADAnalyzer initialized at {sample_rate} Hz, frames={num_frames}") - def _apply_vad_parameters(self) -> None: - vad_ctx = self._vad_ctx - if vad_ctx is None: - return - # Per-parameter try/except so a single SDK rejection doesn't silently - # drop the remaining params. - pending: list[tuple[VadParameter, float | None]] = [ - (VadParameter.SpeechHoldDuration, self._pending_speech_hold_duration), - (VadParameter.MinimumSpeechDuration, self._pending_minimum_speech_duration), - (VadParameter.Sensitivity, self._pending_sensitivity), - ] - for parameter, value in pending: - if value is None: - continue - try: - vad_ctx.set_parameter(parameter, value) - except Exception as e: # noqa: BLE001 - one bad param shouldn't drop the others - logger.warning(f"Quail VAD parameter {parameter} application failed: {e}") - def set_sample_rate(self, sample_rate: int) -> None: """Set the sample rate. Recreates the SDK processor if the rate changed. @@ -305,10 +262,12 @@ def voice_confidence(self, buffer: bytes) -> float: :meth:`num_frames_required` samples. Returns: - ``1.0`` if the VAD reports speech, ``0.0`` otherwise. Returns - ``0.0`` if the processor is not yet initialized (i.e. - :meth:`set_sample_rate` has not run), if the buffer size does not - match the expected window, or if an SDK inference error occurs. + The model's raw speech probability in ``[0.0, 1.0]``. The base + :class:`VADAnalyzer` compares this against ``VADParams.confidence`` + to decide speech. Returns ``0.0`` if the processor is not yet + initialized (i.e. :meth:`set_sample_rate` has not run), if the buffer + size does not match the expected window, or if an SDK inference error + occurs. """ if self._processor is None or self._vad_ctx is None or self._in_f32 is None: return 0.0 @@ -330,7 +289,10 @@ def voice_confidence(self, buffer: bytes) -> float: # Successful inference re-arms the error latch so a fresh error # after a recovery is reported at ERROR rather than buried at DEBUG. self._inference_error_logged = False - return 1.0 if self._vad_ctx.is_speech_detected() else 0.0 + # Raw model probability (no SDK post-processing); clamp defensively + # to the [0.0, 1.0] the VADAnalyzer state machine expects. + probability = float(self._vad_ctx.raw_vad_probability()) + return max(0.0, min(1.0, probability)) except Exception as e: # noqa: BLE001 - keep the pipeline alive on SDK errors if not self._inference_error_logged: logger.error(f"Quail VAD inference error: {e}") diff --git a/src/pipecat/metrics/metrics.py b/src/pipecat/metrics/metrics.py index ee8b52cb4b3..4f6741f04b0 100644 --- a/src/pipecat/metrics/metrics.py +++ b/src/pipecat/metrics/metrics.py @@ -155,3 +155,31 @@ class SmartTurnMetricsData(TurnMetricsData): inference_time_ms: float = 0.0 server_total_time_ms: float = 0.0 + + +class AICAudioQualityMetricsData(MetricsData): + """Audio-quality scores from the ai-coustics Tyto analysis model. + + Each score is in the range ``0.0``–``1.0``. For every field **except** + ``speaker_loudness``, lower values indicate less problematic audio; the + scores predict the likelihood that the analyzed audio degrades downstream + models (speech-to-text, VAD, turn-taking, speech-to-speech). Emitted by + :class:`pipecat.processors.audio.aic_tyto_analyzer.AICTytoAnalyzer`. + + Parameters: + risk_score: Overall likelihood of downstream-model degradation. + speaker_reverb: Reverberation on the primary speaker. + speaker_loudness: Primary-speaker loudness (not a lower-is-better score). + interfering_speech: Presence of competing/background speech. + media_speech: Presence of speech from media (TV, music, etc.). + noise: Non-speech background noise. + packet_loss: Audio artifacts consistent with network packet loss. + """ + + risk_score: float + speaker_reverb: float + speaker_loudness: float + interfering_speech: float + media_speech: float + noise: float + packet_loss: float diff --git a/src/pipecat/processors/audio/aic_tyto_analyzer.py b/src/pipecat/processors/audio/aic_tyto_analyzer.py new file mode 100644 index 00000000000..d8646fe1c66 --- /dev/null +++ b/src/pipecat/processors/audio/aic_tyto_analyzer.py @@ -0,0 +1,312 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +"""Real-time audio-quality analyzer powered by the ai-coustics Tyto model. + +The Tyto analysis model scores incoming audio to predict how likely it is to +degrade downstream models (speech-to-text, VAD, turn-taking, speech-to-speech). +:class:`AICTytoAnalyzer` taps the pipeline's input audio, buffers it into the +SDK's :class:`aic_sdk.Collector`, and periodically runs the (computationally +expensive, non-real-time-safe) analysis off the event loop, emitting an +:class:`pipecat.metrics.metrics.AICAudioQualityMetricsData` via a +:class:`MetricsFrame` and an ``on_audio_analysis`` event. + +Classes: + AICTytoAnalyzer: Periodic audio-quality analysis FrameProcessor. +""" + +from __future__ import annotations + +import asyncio +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from typing import TYPE_CHECKING + +import numpy as np +from aic_sdk import ( + Model, + ProcessorConfig, + analyzer_pair, + set_sdk_id, +) +from loguru import logger + +from pipecat.frames.frames import ( + Frame, + InputAudioRawFrame, + MetricsFrame, + StartFrame, +) +from pipecat.metrics.metrics import AICAudioQualityMetricsData +from pipecat.processors.frame_processor import FrameDirection, FrameProcessor + +if TYPE_CHECKING: + from aic_sdk import AnalysisResult, Analyzer, Collector + +DEFAULT_TYTO_MODEL_ID = "tyto-l-16khz" + +# Telemetry identifier registered with the AIC SDK; identifies pipecat to the +# vendor's usage pipeline. Mirrors the value used by AICFilter / AICQuailVADAnalyzer. +_AIC_SDK_PIPECAT_ID = 6 + +# 2^15: normalizes int16 samples (-32768..32767) to float32 (-1.0..0.99997). +_INT16_DTYPE = np.int16 +_INT16_SCALE = 32768.0 + + +class AICTytoAnalyzer(FrameProcessor): + """Periodic audio-quality analysis using the ai-coustics Tyto model. + + The processor is a passive tap: every frame it receives is forwarded + unchanged in its original direction. On the side, it converts each + :class:`InputAudioRawFrame` to float32 and buffers it into the SDK + :class:`aic_sdk.Collector` (audio-thread safe), while a background task runs + :meth:`aic_sdk.Analyzer.analyze_buffered` every ``analysis_interval`` seconds + on a dedicated thread (the analysis is not real-time safe). Each result is + published as an :class:`AICAudioQualityMetricsData` in a :class:`MetricsFrame` + and dispatched to ``on_audio_analysis`` handlers. + + Place it wherever the audio you want to score flows: right after + ``transport.input()`` to score the raw microphone signal, or after an + :class:`pipecat.audio.filters.aic_filter.AICFilter` to score enhanced audio. + + Event handlers: + + - on_audio_analysis: Called with the :class:`AICAudioQualityMetricsData` for + each completed analysis. + + Example:: + + analyzer = AICTytoAnalyzer(license_key=os.environ["AIC_SDK_LICENSE"]) + + @analyzer.event_handler("on_audio_analysis") + async def on_audio_analysis(processor, scores): + logger.info(f"risk={scores.risk_score:.2f} noise={scores.noise:.2f}") + + pipeline = Pipeline([transport.input(), analyzer, ...]) + """ + + def __init__( + self, + *, + license_key: str, + model_id: str | None = DEFAULT_TYTO_MODEL_ID, + model_path: Path | None = None, + model_download_dir: Path | None = None, + analysis_interval: float = 1.0, + **kwargs, + ) -> None: + """Initialize the Tyto audio-quality analyzer. + + Loads the model eagerly so the cold-start CDN download happens at + construction time (typically before the event loop starts) rather than + on the first audio frame. + + Args: + license_key: ai-coustics SDK license key. + model_id: Tyto analysis model identifier. Defaults to + ``"tyto-l-16khz"``. See https://artifacts.ai-coustics.io/ for the + catalogue. Ignored if ``model_path`` is provided. + model_path: Optional path to a local ``.aicmodel`` file. Overrides + ``model_id`` when set. + model_download_dir: Directory for downloaded models. Defaults to + ``~/.cache/pipecat/aic-models``. + analysis_interval: Seconds between analysis runs. Defaults to 1.0. + **kwargs: Additional arguments passed to :class:`FrameProcessor`. + + Raises: + ValueError: If neither ``model_id`` nor ``model_path`` is provided. + """ + if model_id is None and model_path is None: + raise ValueError( + "Either 'model_id' or 'model_path' must be provided. " + "See https://artifacts.ai-coustics.io/ for available models." + ) + + super().__init__(**kwargs) + + self._license_key = license_key + self._model_id = model_id + self._model_path = model_path + self._model_download_dir = model_download_dir or ( + Path.home() / ".cache" / "pipecat" / "aic-models" + ) + self._analysis_interval = analysis_interval + + self._model: Model | None = None + self._collector: Collector | None = None + self._analyzer: Analyzer | None = None + self._sample_rate = 0 + self._num_channels = 0 + self._analysis_task: asyncio.Task | None = None + # Latch: log analysis errors at ERROR once, then DEBUG until a success + # re-arms it (so a recovery followed by a new failure surfaces again). + self._analysis_error_logged = False + + # Blocking analysis runs here, off the event loop. analyze_buffered() is + # not real-time safe, so it must never run on the audio/event-loop path. + self._executor = ThreadPoolExecutor(max_workers=1) + + self._register_event_handler("on_audio_analysis") + + # Eager model load shifts the CDN download out of the hot path. If it + # raises, shut down the executor so the half-constructed instance does + # not leak its worker thread, then propagate. + try: + set_sdk_id(_AIC_SDK_PIPECAT_ID) + self._ensure_model_loaded() + except Exception: + try: + self._executor.shutdown(wait=False) + except Exception as e: # noqa: BLE001 - executor cleanup is best-effort + logger.debug(f"AICTytoAnalyzer executor shutdown failed: {e}") + raise + + def _ensure_model_loaded(self) -> None: + if self._model is not None: + return + if self._model_path is not None: + logger.debug(f"Loading Tyto model from file: {self._model_path}") + self._model = Model.from_file(str(self._model_path)) + return + # model_id path (validated in __init__). + assert self._model_id is not None + self._model_download_dir.mkdir(parents=True, exist_ok=True) + logger.debug(f"Downloading Tyto model {self._model_id!r} to {self._model_download_dir}") + model_path = Model.download(self._model_id, str(self._model_download_dir)) + self._model = Model.from_file(model_path) + + def _initialize_collector(self, sample_rate: int, num_channels: int) -> None: + self._ensure_model_loaded() + assert self._model is not None + + collector, analyzer = analyzer_pair(self._model, self._license_key) + # allow_variable_frames so we can buffer whatever chunk size the + # transport delivers per InputAudioRawFrame without re-blocking. + config = ProcessorConfig.optimal( + self._model, + sample_rate=sample_rate, + num_channels=num_channels, + allow_variable_frames=True, + ) + collector.initialize(config) + + self._collector = collector + self._analyzer = analyzer + self._sample_rate = sample_rate + self._num_channels = num_channels + self._analysis_error_logged = False + logger.debug(f"AICTytoAnalyzer initialized at {sample_rate} Hz, {num_channels} channel(s)") + + async def process_frame(self, frame: Frame, direction: FrameDirection) -> None: + """Forward every frame unchanged and tap input audio for analysis. + + Args: + frame: The frame to process. + direction: The direction of frame flow in the pipeline. + """ + await super().process_frame(frame, direction) + + # Passive tap: forward first so audio/control flow is never delayed by + # the analysis side-channel. + await self.push_frame(frame, direction) + + if isinstance(frame, StartFrame): + self._start() + elif isinstance(frame, InputAudioRawFrame): + self._buffer_audio(frame) + + def _start(self) -> None: + if self._analysis_task is None: + self._analysis_task = self.create_task(self._analysis_loop(), f"{self}::analysis_loop") + + def _buffer_audio(self, frame: InputAudioRawFrame) -> None: + channels = frame.num_channels or 1 + # Lazily (re)initialize once the concrete rate/channel layout is known. + if ( + self._collector is None + or frame.sample_rate != self._sample_rate + or channels != self._num_channels + ): + self._initialize_collector(frame.sample_rate, channels) + assert self._collector is not None + + samples = np.frombuffer(frame.audio, dtype=_INT16_DTYPE).astype(np.float32) + samples /= _INT16_SCALE + # Collector expects a 2D (channels, frames) array; de-interleave for + # multi-channel input (the model mixes to mono internally). + if channels > 1: + audio = samples.reshape(-1, channels).T + else: + audio = samples.reshape(1, -1) + try: + self._collector.buffer(audio) + except Exception as e: # noqa: BLE001 - keep the pipeline alive on SDK errors + if not self._analysis_error_logged: + logger.error(f"Tyto buffering error: {e}") + self._analysis_error_logged = True + else: + logger.debug(f"Tyto buffering error: {e}") + + async def _analysis_loop(self) -> None: + while True: + await asyncio.sleep(self._analysis_interval) + await self._analyze_once() + + async def _analyze_once(self) -> None: + """Run one analysis pass and publish the result. + + The analysis runs off the event loop (it is not real-time safe); SDK + errors are latched and swallowed so the pipeline stays alive. + """ + analyzer = self._analyzer + if analyzer is None: + return + loop = asyncio.get_running_loop() + try: + result: AnalysisResult = await loop.run_in_executor( + self._executor, analyzer.analyze_buffered + ) + # Successful analysis re-arms the error latch. + self._analysis_error_logged = False + except Exception as e: # noqa: BLE001 - keep the pipeline alive on SDK errors + if not self._analysis_error_logged: + logger.error(f"Tyto analysis error: {e}") + self._analysis_error_logged = True + else: + logger.debug(f"Tyto analysis error: {e}") + return + + data = self._build_metrics(result) + await self.push_frame(MetricsFrame(data=[data])) + await self._call_event_handler("on_audio_analysis", data) + + def _build_metrics(self, result: AnalysisResult) -> AICAudioQualityMetricsData: + return AICAudioQualityMetricsData( + processor=self.name, + model=self._model_id, + risk_score=result.risk_score, + speaker_reverb=result.speaker_reverb, + speaker_loudness=result.speaker_loudness, + interfering_speech=result.interfering_speech, + media_speech=result.media_speech, + noise=result.noise, + packet_loss=result.packet_loss, + ) + + async def cleanup(self) -> None: + """Cancel the analysis task and release the SDK handles.""" + await super().cleanup() + if self._analysis_task is not None: + await self.cancel_task(self._analysis_task) + self._analysis_task = None + try: + self._executor.shutdown(wait=False) + except Exception as e: # noqa: BLE001 - cleanup is best-effort + logger.debug(f"AICTytoAnalyzer executor shutdown failed: {e}") + self._collector = None + self._analyzer = None + self._model = None diff --git a/src/pipecat/processors/frameworks/rtvi/observer.py b/src/pipecat/processors/frameworks/rtvi/observer.py index c4b63b01440..05ba4cc7735 100644 --- a/src/pipecat/processors/frameworks/rtvi/observer.py +++ b/src/pipecat/processors/frameworks/rtvi/observer.py @@ -54,6 +54,7 @@ VADUserStoppedSpeakingFrame, ) from pipecat.metrics.metrics import ( + AICAudioQualityMetricsData, LLMUsageMetricsData, ProcessingMetricsData, TTFBMetricsData, @@ -839,6 +840,10 @@ async def _handle_metrics(self, frame: MetricsFrame): if "characters" not in metrics: metrics["characters"] = [] metrics["characters"].append(d.model_dump(exclude_none=True)) + elif isinstance(d, AICAudioQualityMetricsData): + if "audio_quality" not in metrics: + metrics["audio_quality"] = [] + metrics["audio_quality"].append(d.model_dump(exclude_none=True)) message = RTVI.MetricsMessage(data=metrics) await self.send_rtvi_message(message) diff --git a/tests/aic_mocks.py b/tests/aic_mocks.py index 1607c88207c..4312ba582b3 100644 --- a/tests/aic_mocks.py +++ b/tests/aic_mocks.py @@ -7,8 +7,9 @@ """Shared aic_sdk test mocks for the AIC test suite. Importing in: ``tests/test_aic_filter.py``, ``tests/test_aic_vad.py``, -``tests/test_aic_quail_vad.py``. Keep behavior aligned with the live -``aic_sdk`` 2.3.0 surface so the suite stays representative. +``tests/test_aic_quail_vad.py``, ``tests/test_aic_tyto_analyzer.py``. Keep +behavior aligned with the live ``aic_sdk`` 2.5.0 surface so the suite stays +representative. """ from typing import Any @@ -22,10 +23,15 @@ class MockVadContext: def __init__( self, speech_detected: bool = False, + raw_probability: float = 0.0, raise_on_detect: bool = False, raise_on_set_param: bool = False, ) -> None: self.speech_detected = speech_detected + self.raw_probability = raw_probability + # raise_on_detect drives both query paths so error tests can target + # whichever the code under test calls (is_speech_detected / + # raw_vad_probability). self.raise_on_detect = raise_on_detect self.raise_on_set_param = raise_on_set_param self.parameters_set: list[tuple] = [] @@ -35,6 +41,11 @@ def is_speech_detected(self) -> bool: raise RuntimeError("VAD error") return self.speech_detected + def raw_vad_probability(self) -> float: + if self.raise_on_detect: + raise RuntimeError("VAD error") + return self.raw_probability + def set_parameter(self, param: Any, value: float) -> None: if self.raise_on_set_param: raise RuntimeError("Param error") @@ -120,3 +131,69 @@ def get_optimal_num_frames(self, sample_rate: int) -> int: def get_optimal_sample_rate(self) -> int: return self._optimal_sample_rate + + +class MockAnalysisResult: + """Stand-in for ``aic_sdk.AnalysisResult`` (Tyto).""" + + def __init__( + self, + risk_score: float = 0.0, + speaker_reverb: float = 0.0, + speaker_loudness: float = 0.0, + interfering_speech: float = 0.0, + media_speech: float = 0.0, + noise: float = 0.0, + packet_loss: float = 0.0, + ) -> None: + self.risk_score = risk_score + self.speaker_reverb = speaker_reverb + self.speaker_loudness = speaker_loudness + self.interfering_speech = interfering_speech + self.media_speech = media_speech + self.noise = noise + self.packet_loss = packet_loss + + +class MockCollector: + """Stand-in for ``aic_sdk.Collector`` (Tyto). + + Records ``initialize`` configs and buffered arrays so tests can assert on the + audio tap. ``raise_on_buffer`` exercises the buffering error path. + """ + + def __init__(self, raise_on_buffer: bool = False) -> None: + self.raise_on_buffer = raise_on_buffer + self.initialized_with: list[Any] = [] + self.buffer_calls: list[np.ndarray] = [] + + def initialize(self, config: Any) -> None: + self.initialized_with.append(config) + + def buffer(self, audio: np.ndarray) -> None: + if self.raise_on_buffer: + raise RuntimeError("buffer error") + self.buffer_calls.append(audio.copy()) + + +class MockAnalyzer: + """Stand-in for ``aic_sdk.Analyzer`` (Tyto). + + Returns ``result`` from ``analyze_buffered`` (default all-zeros); + ``raise_on_analyze`` exercises the analysis error path. + """ + + def __init__( + self, + result: MockAnalysisResult | None = None, + raise_on_analyze: bool = False, + ) -> None: + self.result = result or MockAnalysisResult() + self.raise_on_analyze = raise_on_analyze + self.analyze_calls = 0 + + def analyze_buffered(self) -> MockAnalysisResult: + self.analyze_calls += 1 + if self.raise_on_analyze: + raise RuntimeError("analyze error") + return self.result diff --git a/tests/test_aic_quail_vad.py b/tests/test_aic_quail_vad.py index e9c6bf57bbb..3300cfd78d8 100644 --- a/tests/test_aic_quail_vad.py +++ b/tests/test_aic_quail_vad.py @@ -40,7 +40,7 @@ def setUpClass(cls): cls.DEFAULT_QUAIL_VAD_MODEL_ID = DEFAULT_QUAIL_VAD_MODEL_ID def setUp(self): - self.mock_model = MockModel(model_id="quail-vad-2.0-xxs-16khz", optimal_num_frames=160) + self.mock_model = MockModel(model_id="quail-vf-vad-2.0-s-16khz", optimal_num_frames=160) self.mock_processor = MockProcessorSync() def _create_analyzer(self, **kwargs): @@ -197,9 +197,9 @@ def test_init_shuts_down_executor_on_processor_init_failure(self): mock_executor_instance.shutdown.assert_called_once_with(wait=False) def test_default_model_id(self): - """Default model_id is the published standalone Quail VAD.""" + """Default model_id is the published standalone VF VAD 2.0 model.""" analyzer, _ = self._create_analyzer() - self.assertEqual(analyzer._model_id, "quail-vad-2.0-xxs-16khz") + self.assertEqual(analyzer._model_id, "quail-vf-vad-2.0-s-16khz") self.assertEqual(analyzer._model_id, self.DEFAULT_QUAIL_VAD_MODEL_ID) def test_default_download_dir(self): @@ -214,22 +214,11 @@ def test_custom_download_dir(self): analyzer, _ = self._create_analyzer(model_download_dir=custom) self.assertEqual(analyzer._model_download_dir, custom) - def test_pending_vad_params_stored(self): - """Constructor stashes optional VAD knobs for later application.""" - analyzer, _ = self._create_analyzer( - speech_hold_duration=0.08, - minimum_speech_duration=0.05, - sensitivity=0.7, - ) - self.assertEqual(analyzer._pending_speech_hold_duration, 0.08) - self.assertEqual(analyzer._pending_minimum_speech_duration, 0.05) - self.assertEqual(analyzer._pending_sensitivity, 0.7) - def test_construction_eagerly_loads_model_via_model_id(self): """__init__ downloads and loads the model so cold-start happens off-hot-path.""" _, mocks = self._create_analyzer() mocks["Model"].download.assert_called_once_with( - "quail-vad-2.0-xxs-16khz", + "quail-vf-vad-2.0-s-16khz", str(Path.home() / ".cache" / "pipecat" / "aic-models"), ) mocks["Model"].from_file.assert_called_once_with("/tmp/test.aicmodel") @@ -441,22 +430,37 @@ def test_voice_confidence_before_init_returns_zero(self): analyzer, _ = self._create_analyzer() self.assertEqual(analyzer.voice_confidence(b"\x00" * 320), 0.0) - def test_voice_confidence_reports_speech(self): - """When VAD says speech, return 1.0.""" + def test_voice_confidence_returns_raw_probability(self): + """voice_confidence returns the model's raw probability verbatim. + + Pipecat's VADParams.confidence (not the SDK) decides speech, so the + analyzer must surface the continuous value rather than a 0/1 boolean. + """ analyzer, _ = self._create_analyzer() self._initialize_at(analyzer, 16000) - self.mock_processor.vad_ctx.speech_detected = True + self.mock_processor.vad_ctx.raw_probability = 0.42 # 10 ms at 16 kHz int16 → 320 bytes. confidence = analyzer.voice_confidence(b"\x00" * 320) - self.assertEqual(confidence, 1.0) + self.assertAlmostEqual(confidence, 0.42) self.assertEqual(len(self.mock_processor.process_calls), 1) self.assertEqual(self.mock_processor.process_calls[0].shape, (1, 160)) - def test_voice_confidence_reports_silence(self): - """When VAD says no speech, return 0.0.""" + def test_voice_confidence_reports_high_and_low(self): + """High raw probability ≈ speech, low ≈ silence — both pass through.""" + analyzer, _ = self._create_analyzer() + self._initialize_at(analyzer, 16000) + self.mock_processor.vad_ctx.raw_probability = 0.97 + self.assertAlmostEqual(analyzer.voice_confidence(b"\x00" * 320), 0.97) + self.mock_processor.vad_ctx.raw_probability = 0.01 + self.assertAlmostEqual(analyzer.voice_confidence(b"\x00" * 320), 0.01) + + def test_voice_confidence_clamps_out_of_range(self): + """Probabilities outside [0.0, 1.0] are clamped to the VADAnalyzer range.""" analyzer, _ = self._create_analyzer() self._initialize_at(analyzer, 16000) - self.mock_processor.vad_ctx.speech_detected = False + self.mock_processor.vad_ctx.raw_probability = 1.5 + self.assertEqual(analyzer.voice_confidence(b"\x00" * 320), 1.0) + self.mock_processor.vad_ctx.raw_probability = -0.2 self.assertEqual(analyzer.voice_confidence(b"\x00" * 320), 0.0) def test_voice_confidence_swallows_sdk_errors(self): @@ -466,12 +470,12 @@ def test_voice_confidence_swallows_sdk_errors(self): self.mock_processor.process = MagicMock(side_effect=RuntimeError("boom")) self.assertEqual(analyzer.voice_confidence(b"\x00" * 320), 0.0) - def test_voice_confidence_swallows_is_speech_detected_errors(self): - """is_speech_detected() raising after process() succeeds returns 0.0 + def test_voice_confidence_swallows_raw_probability_errors(self): + """raw_vad_probability() raising after process() succeeds returns 0.0 and re-arms the error latch (distinct path from process() failure).""" analyzer, _ = self._create_analyzer() self._initialize_at(analyzer, 16000) - # process() succeeds; the failure happens in is_speech_detected(). + # process() succeeds; the failure happens in raw_vad_probability(). self.mock_processor.vad_ctx.raise_on_detect = True self.assertEqual(analyzer.voice_confidence(b"\x00" * 320), 0.0) self.assertTrue(analyzer._inference_error_logged) @@ -530,54 +534,6 @@ def test_buffer_size_warning_latch_resets_on_reinit(self): analyzer.set_sample_rate(16000) self.assertFalse(analyzer._buffer_size_warning_logged) - # --- VAD parameters ------------------------------------------------------ - - def test_vad_params_applied_to_vad_context(self): - """Constructor-supplied tuning knobs are pushed to VadContext.""" - analyzer, _ = self._create_analyzer( - speech_hold_duration=0.08, - minimum_speech_duration=0.05, - sensitivity=0.7, - ) - self._initialize_at(analyzer, 16000) - params = self.mock_processor.vad_ctx.parameters_set - self.assertEqual(len(params), 3) - self.assertEqual([v for _, v in params], [0.08, 0.05, 0.7]) - - def test_vad_parameter_first_failure_does_not_drop_subsequent(self): - """Per-parameter try/except means one failure doesn't silently drop others.""" - analyzer, _ = self._create_analyzer( - speech_hold_duration=0.08, - minimum_speech_duration=0.05, - sensitivity=0.7, - ) - self._initialize_at(analyzer, 16000) - # Configure VadContext to fail on the first parameter only. - call_log = [] - - def selective_set(param, value): - call_log.append((param, value)) - if len(call_log) == 1: - raise RuntimeError("first param rejected") - - analyzer._vad_ctx.set_parameter = selective_set - analyzer._apply_vad_parameters() - # All three params should have been attempted despite the first failure. - self.assertEqual(len(call_log), 3) - - def test_vad_parameter_application_swallows_errors(self): - """A failing set_parameter call is logged, not re-raised.""" - analyzer, _ = self._create_analyzer(sensitivity=0.7) - self._initialize_at(analyzer, 16000) - analyzer._vad_ctx.set_parameter = MagicMock(side_effect=RuntimeError("boom")) - analyzer._apply_vad_parameters() # must not raise - - def test_apply_vad_parameters_noop_without_context(self): - """_apply_vad_parameters returns early when there is no VadContext.""" - analyzer, _ = self._create_analyzer(sensitivity=0.7) - analyzer._vad_ctx = None - analyzer._apply_vad_parameters() # must not raise - # --- Cleanup ------------------------------------------------------------- async def test_cleanup_releases_resources(self): diff --git a/tests/test_aic_tyto_analyzer.py b/tests/test_aic_tyto_analyzer.py new file mode 100644 index 00000000000..fde4a47479d --- /dev/null +++ b/tests/test_aic_tyto_analyzer.py @@ -0,0 +1,318 @@ +# +# Copyright (c) 2024-2026, Daily +# +# SPDX-License-Identifier: BSD 2-Clause License +# + +import unittest +from pathlib import Path +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import numpy as np + +# Check if aic_sdk is available +aic_sdk: Any +try: + import aic_sdk + + HAS_AIC_SDK = True +except ImportError: + aic_sdk = None + HAS_AIC_SDK = False + +from tests.aic_mocks import ( # noqa: E402 + MockAnalysisResult, + MockAnalyzer, + MockCollector, + MockModel, +) + +# Module path for patching +TYTO_MODULE = "pipecat.processors.audio.aic_tyto_analyzer" + + +@unittest.skipUnless(HAS_AIC_SDK, "aic-sdk not installed") +class TestAICTytoAnalyzer(unittest.IsolatedAsyncioTestCase): + """Test suite for AICTytoAnalyzer using mocked aic_sdk types.""" + + @classmethod + def setUpClass(cls): + from pipecat.metrics.metrics import AICAudioQualityMetricsData + from pipecat.processors.audio.aic_tyto_analyzer import ( + DEFAULT_TYTO_MODEL_ID, + AICTytoAnalyzer, + ) + + cls.AICTytoAnalyzer = AICTytoAnalyzer + cls.DEFAULT_TYTO_MODEL_ID = DEFAULT_TYTO_MODEL_ID + cls.AICAudioQualityMetricsData = AICAudioQualityMetricsData + + def setUp(self): + self.mock_model = MockModel(model_id="tyto-l-16khz") + self.mock_collector = MockCollector() + self.mock_analyzer = MockAnalyzer() + + # Patch every aic_sdk touchpoint for the whole test, so lazy collector + # init inside _buffer_audio is mocked too (not just __init__). + patchers = { + "set_sdk_id": patch(f"{TYTO_MODULE}.set_sdk_id"), + "Model": patch(f"{TYTO_MODULE}.Model"), + "ProcessorConfig": patch(f"{TYTO_MODULE}.ProcessorConfig"), + "analyzer_pair": patch( + f"{TYTO_MODULE}.analyzer_pair", + return_value=(self.mock_collector, self.mock_analyzer), + ), + } + self.mocks = {name: p.start() for name, p in patchers.items()} + for p in patchers.values(): + self.addCleanup(p.stop) + + self.mocks["Model"].from_file.return_value = self.mock_model + self.mocks["Model"].download.return_value = "/tmp/test.aicmodel" + self.mocks["ProcessorConfig"].optimal.return_value = MagicMock(name="config") + + def _make(self, **kwargs): + analyzer_kwargs = {"license_key": "test-key"} + analyzer_kwargs.update(kwargs) + return self.AICTytoAnalyzer(**analyzer_kwargs) + + @staticmethod + def _audio_frame(*, num_samples: int, sample_rate: int = 16000, num_channels: int = 1): + from pipecat.frames.frames import InputAudioRawFrame + + audio = (np.arange(num_samples, dtype=np.int16)).tobytes() + return InputAudioRawFrame(audio=audio, sample_rate=sample_rate, num_channels=num_channels) + + # --- Construction -------------------------------------------------------- + + def test_requires_model_id_or_path(self): + """Neither model_id nor model_path → ValueError.""" + with self.assertRaises(ValueError): + self._make(model_id=None, model_path=None) + + def test_default_model_id(self): + """Default model_id is the published Tyto model.""" + analyzer = self._make() + self.assertEqual(analyzer._model_id, "tyto-l-16khz") + self.assertEqual(analyzer._model_id, self.DEFAULT_TYTO_MODEL_ID) + + def test_default_download_dir(self): + """Default model_download_dir lives under the user's cache.""" + analyzer = self._make() + expected = Path.home() / ".cache" / "pipecat" / "aic-models" + self.assertEqual(analyzer._model_download_dir, expected) + + def test_eager_loads_model_via_model_id(self): + """__init__ registers telemetry and downloads/loads the model.""" + self._make() + self.mocks["set_sdk_id"].assert_called_once_with(6) + self.mocks["Model"].download.assert_called_once_with( + "tyto-l-16khz", + str(Path.home() / ".cache" / "pipecat" / "aic-models"), + ) + self.mocks["Model"].from_file.assert_called_once_with("/tmp/test.aicmodel") + + def test_eager_loads_model_via_model_path(self): + """model_path skips the download step entirely.""" + self._make(model_id=None, model_path=Path("/tmp/custom.aicmodel")) + self.mocks["Model"].download.assert_not_called() + self.mocks["Model"].from_file.assert_called_once_with("/tmp/custom.aicmodel") + + def test_init_shuts_down_executor_on_eager_load_failure(self): + """A failed eager load tears down the executor so no worker thread leaks.""" + mock_executor = MagicMock() + with ( + patch(f"{TYTO_MODULE}.ThreadPoolExecutor", return_value=mock_executor), + patch.object(self.mocks["Model"], "download", side_effect=RuntimeError("network")), + ): + with self.assertRaises(RuntimeError): + self._make() + mock_executor.shutdown.assert_called_once_with(wait=False) + + # --- Audio buffering ----------------------------------------------------- + + def test_buffer_initializes_collector_with_variable_frames(self): + """First audio frame lazily builds the collector with allow_variable_frames.""" + analyzer = self._make() + analyzer._buffer_audio(self._audio_frame(num_samples=160)) + + self.mocks["analyzer_pair"].assert_called_once() + self.mocks["ProcessorConfig"].optimal.assert_called_once() + _, kwargs = self.mocks["ProcessorConfig"].optimal.call_args + self.assertTrue(kwargs["allow_variable_frames"]) + self.assertEqual(kwargs["sample_rate"], 16000) + self.assertEqual(kwargs["num_channels"], 1) + self.assertEqual(len(self.mock_collector.buffer_calls), 1) + self.assertEqual(self.mock_collector.buffer_calls[0].shape, (1, 160)) + self.assertEqual(self.mock_collector.buffer_calls[0].dtype, np.float32) + + def test_buffer_normalizes_int16(self): + """int16 samples are scaled into [-1.0, 1.0) float32.""" + analyzer = self._make() + analyzer._buffer_audio(self._audio_frame(num_samples=4)) + buffered = self.mock_collector.buffer_calls[0] + # Samples were 0,1,2,3 → divided by 32768. + np.testing.assert_allclose(buffered[0], np.array([0, 1, 2, 3], dtype=np.float32) / 32768.0) + + def test_buffer_deinterleaves_multichannel(self): + """Stereo audio is reshaped to (channels, frames).""" + analyzer = self._make() + # 8 interleaved samples → 2 channels × 4 frames. + analyzer._buffer_audio(self._audio_frame(num_samples=8, num_channels=2)) + self.assertEqual(self.mock_collector.buffer_calls[0].shape, (2, 4)) + + def test_buffer_reinitializes_on_sample_rate_change(self): + """A changed input sample rate rebuilds the collector.""" + analyzer = self._make() + analyzer._buffer_audio(self._audio_frame(num_samples=160, sample_rate=16000)) + analyzer._buffer_audio(self._audio_frame(num_samples=80, sample_rate=8000)) + self.assertEqual(self.mocks["analyzer_pair"].call_count, 2) + + def test_buffer_does_not_reinitialize_on_same_config(self): + """Repeated frames at the same rate reuse the collector.""" + analyzer = self._make() + analyzer._buffer_audio(self._audio_frame(num_samples=160)) + analyzer._buffer_audio(self._audio_frame(num_samples=160)) + self.assertEqual(self.mocks["analyzer_pair"].call_count, 1) + self.assertEqual(len(self.mock_collector.buffer_calls), 2) + + def test_buffer_swallows_sdk_errors(self): + """A buffering error is latched and swallowed (pipeline stays alive).""" + analyzer = self._make() + self.mock_collector.raise_on_buffer = True + analyzer._buffer_audio(self._audio_frame(num_samples=160)) # must not raise + self.assertTrue(analyzer._analysis_error_logged) + + # --- Analysis ------------------------------------------------------------ + + def test_build_metrics_maps_all_scores(self): + """_build_metrics copies the 7 Tyto scores plus processor/model.""" + analyzer = self._make() + result = MockAnalysisResult( + risk_score=0.9, + speaker_reverb=0.1, + speaker_loudness=0.5, + interfering_speech=0.2, + media_speech=0.3, + noise=0.4, + packet_loss=0.05, + ) + data = analyzer._build_metrics(result) + self.assertIsInstance(data, self.AICAudioQualityMetricsData) + self.assertEqual(data.processor, analyzer.name) + self.assertEqual(data.model, "tyto-l-16khz") + self.assertAlmostEqual(data.risk_score, 0.9) + self.assertAlmostEqual(data.speaker_reverb, 0.1) + self.assertAlmostEqual(data.speaker_loudness, 0.5) + self.assertAlmostEqual(data.interfering_speech, 0.2) + self.assertAlmostEqual(data.media_speech, 0.3) + self.assertAlmostEqual(data.noise, 0.4) + self.assertAlmostEqual(data.packet_loss, 0.05) + + async def test_analyze_once_emits_metrics_frame(self): + """A successful analysis pushes a MetricsFrame with the scores.""" + from pipecat.frames.frames import MetricsFrame + + analyzer = self._make() + analyzer._analyzer = MockAnalyzer(result=MockAnalysisResult(risk_score=0.8)) + analyzer.push_frame = AsyncMock() + await analyzer._analyze_once() + analyzer.push_frame.assert_awaited_once() + pushed = analyzer.push_frame.await_args.args[0] + self.assertIsInstance(pushed, MetricsFrame) + self.assertIsInstance(pushed.data[0], self.AICAudioQualityMetricsData) + self.assertAlmostEqual(pushed.data[0].risk_score, 0.8) + + async def test_analyze_once_fires_event(self): + """on_audio_analysis handlers receive the metrics data.""" + analyzer = self._make() + analyzer._analyzer = MockAnalyzer(result=MockAnalysisResult(noise=0.6)) + analyzer.push_frame = AsyncMock() + captured = [] + + @analyzer.event_handler("on_audio_analysis") + async def _on(_proc, data): + captured.append(data) + + await analyzer._analyze_once() + # The event handler runs as a fire-and-forget task; let it run. + import asyncio + + await asyncio.sleep(0) + self.assertEqual(len(captured), 1) + self.assertAlmostEqual(captured[0].noise, 0.6) + + async def test_analyze_once_without_analyzer_is_noop(self): + """No analysis runs before the collector/analyzer is initialized.""" + analyzer = self._make() + analyzer._analyzer = None + analyzer.push_frame = AsyncMock() + await analyzer._analyze_once() + analyzer.push_frame.assert_not_called() + + async def test_analyze_once_swallows_sdk_errors(self): + """An analysis error is latched and swallowed; nothing is pushed.""" + analyzer = self._make() + analyzer._analyzer = MockAnalyzer(raise_on_analyze=True) + analyzer.push_frame = AsyncMock() + await analyzer._analyze_once() + analyzer.push_frame.assert_not_called() + self.assertTrue(analyzer._analysis_error_logged) + + # --- Lifecycle ----------------------------------------------------------- + + def test_start_spawns_task_once(self): + """_start spawns the analysis loop exactly once.""" + analyzer = self._make() + + def _fake_create_task(coro, *args, **kwargs): + coro.close() # avoid an un-awaited-coroutine warning + return MagicMock() + + analyzer.create_task = MagicMock(side_effect=_fake_create_task) + analyzer._start() + analyzer._start() + analyzer.create_task.assert_called_once() + + async def test_cleanup_releases_handles(self): + """cleanup() shuts the executor and nils SDK handles.""" + analyzer = self._make() + analyzer._collector = self.mock_collector + analyzer._analyzer = self.mock_analyzer + await analyzer.cleanup() + self.assertIsNone(analyzer._collector) + self.assertIsNone(analyzer._analyzer) + self.assertIsNone(analyzer._model) + + async def test_cleanup_cancels_analysis_task(self): + """cleanup() cancels a running analysis task.""" + analyzer = self._make() + analyzer.cancel_task = AsyncMock() + sentinel = MagicMock() + analyzer._analysis_task = sentinel + await analyzer.cleanup() + analyzer.cancel_task.assert_awaited_once_with(sentinel) + self.assertIsNone(analyzer._analysis_task) + + # --- Integration (passive tap) ------------------------------------------ + + async def test_forwards_frames_and_taps_audio(self): + """Frames pass through unchanged while input audio is buffered.""" + from pipecat.frames.frames import InputAudioRawFrame, TextFrame + from pipecat.tests.utils import run_test + + # Large interval so the analysis loop never fires during the test. + analyzer = self._make(analysis_interval=100.0) + audio = self._audio_frame(num_samples=160) + received_down, _ = await run_test( + analyzer, + frames_to_send=[audio, TextFrame("hello")], + expected_down_frames=[InputAudioRawFrame, TextFrame], + ) + # The input audio frame was tapped into the collector. + self.assertGreaterEqual(len(self.mock_collector.buffer_calls), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/uv.lock b/uv.lock index 1441a9c939c..cb59bc5f7ec 100644 --- a/uv.lock +++ b/uv.lock @@ -28,37 +28,37 @@ wheels = [ [[package]] name = "aic-sdk" -version = "2.3.0" +version = "2.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/be/69/24e928d4ce876015086a3b6b713d260682e6dff343be52f8c4dc350da97d/aic_sdk-2.3.0.tar.gz", hash = "sha256:ebeae4255ea2b9a19e5a3e8d5828d0d0ce82921fca2876e2b4b580e4e30441dc", size = 3349064, upload-time = "2026-05-29T13:41:23.973Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/39/25/6f04d7117b757402cce6db5d9a626e8402a820abf14cfbddfed57d15ae02/aic_sdk-2.3.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8d364dbbf425995668cfa512a221f6cf9df6974cf9ac64f2ac0ca0f5ba5615a0", size = 3874042, upload-time = "2026-05-29T13:40:45.268Z" }, - { url = "https://files.pythonhosted.org/packages/45/ec/1c60d57c5c5518572e6cad4e840d6d7607b503b095257c05b9ad4ca8f8f2/aic_sdk-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2221a5b446280479a374e4dacf81d830334229a9ea60fe1f71ee9be3126af8bd", size = 3472443, upload-time = "2026-05-29T13:40:47.528Z" }, - { url = "https://files.pythonhosted.org/packages/f9/a7/a3502e94920a031bb5eed8576cf955b7fdd5f6061a26fbc20501274729d6/aic_sdk-2.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:792bfeef272a73c08394d5eae40c73df30cf911d2f3f9a86c1c613b40bf3c3b3", size = 3431498, upload-time = "2026-05-29T13:40:49.217Z" }, - { url = "https://files.pythonhosted.org/packages/0f/82/1517951cd12b823947b1ca35b63633af364e955ee4e69bbc1a57b1662484/aic_sdk-2.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d50de871fd0b245c15d50829541189a849459fc1107d818d4c0f7cfafa059e8", size = 3797184, upload-time = "2026-05-29T13:40:50.723Z" }, - { url = "https://files.pythonhosted.org/packages/33/e3/9788cd177423e98dfc923e9c9a0ef39329c8060d0fce0aae65bca1361555/aic_sdk-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:595c081ed50ee7d4ae36bb24a2adbdea08a0fa2cdba666a440c17d69a5eb5576", size = 3249200, upload-time = "2026-05-29T13:40:52.146Z" }, - { url = "https://files.pythonhosted.org/packages/9d/23/9e910370be9bbf80cb7e71c653230ee3d752b9bfd6c760406fd99e846052/aic_sdk-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:8479e912c9faad1ee2242cfd465d46082c7dcba3ad48d9d2b1325e83f33e9fc9", size = 2927856, upload-time = "2026-05-29T13:40:53.456Z" }, - { url = "https://files.pythonhosted.org/packages/e9/ac/6136b5954f8d4464e3783477c8b03e4cb928c332e8c75e7099ddcaf502b3/aic_sdk-2.3.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:6c88a9b1cd195bf3daf4a3cb5d58dfdeea8095604d80afb25886c666f7c27b78", size = 3874269, upload-time = "2026-05-29T13:40:54.998Z" }, - { url = "https://files.pythonhosted.org/packages/49/26/99743e9e53c0ccbd9b5e3decb32278b0df8258a6690ecd82c1f2f2046643/aic_sdk-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ea7abca44c1f34b906685a08f336f9d236724de3510f6e29d56b1e683820df40", size = 3471060, upload-time = "2026-05-29T13:40:56.661Z" }, - { url = "https://files.pythonhosted.org/packages/78/ec/c2a57e05c3bac87a06b4d20c8b9a553027cda7e8d4537327b7071355d755/aic_sdk-2.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9a81d988da108c884806817af0ce137c5be9df1281a48329371a9a556e53c1bf", size = 3429056, upload-time = "2026-05-29T13:41:00.286Z" }, - { url = "https://files.pythonhosted.org/packages/6e/90/54c6cf7e1d24bae3bdd63c7b3dfeb1de94f839b286a389dc9893dd89cf1c/aic_sdk-2.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:705ea971a71268e1414162c0b55f1d269f30dd9f1853e89282dda9335f41e449", size = 3796394, upload-time = "2026-05-29T13:41:01.856Z" }, - { url = "https://files.pythonhosted.org/packages/45/19/5ec9dc5c712165d490c4ed987c473e4a8dd8acd05dff8dd20b72767bae5a/aic_sdk-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:9d997601d080f1d9ac8dc30ce950f2d6532a32b54b97e55a94a2d95ed32745d7", size = 3247562, upload-time = "2026-05-29T13:41:03.316Z" }, - { url = "https://files.pythonhosted.org/packages/88/b7/b29c7cafb3c86c54860395195cd5e7e9c31df2c9d47d059851ba776ab244/aic_sdk-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:cc164e377fe1676fdee4d06c4799f7af7ec113e164ae188bfe4cc1aa5aa3325c", size = 2924028, upload-time = "2026-05-29T13:41:04.785Z" }, - { url = "https://files.pythonhosted.org/packages/26/f1/a98380a9c6aa57e62c6eda9013738cf127605c32cdb6ff78b941c16fe035/aic_sdk-2.3.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:12283da81eb5101becd6923133bcf69f282aaed67cfc32e4c3067361d4efca58", size = 3873741, upload-time = "2026-05-29T13:41:06.028Z" }, - { url = "https://files.pythonhosted.org/packages/98/83/f2de77d9edfaddb200979bd069ee0f3186d3d9a904656a08541899f5c18f/aic_sdk-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0d06f7ee1ac3535cf59e9ea1296bc7496f1e3ca5997a039363f3f1aad5ed1ab5", size = 3471002, upload-time = "2026-05-29T13:41:07.407Z" }, - { url = "https://files.pythonhosted.org/packages/7b/c6/f24eea61ff909738583ef97717ca73ae0c016e79652f9cf269dc5f62f9ec/aic_sdk-2.3.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5024a510f36eec8e5049a753446a5c2554e1ece1b5d285139d77723b52103e5d", size = 3428520, upload-time = "2026-05-29T13:41:09.119Z" }, - { url = "https://files.pythonhosted.org/packages/9b/c5/40484fd5306cc604badd5bad5beb85ba13338f1878a8e5236a338239a3e7/aic_sdk-2.3.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d80075bc9f08d9caa670e21531c9a41293d0847f9a0fe8a206257c759a1d3608", size = 3795651, upload-time = "2026-05-29T13:41:10.532Z" }, - { url = "https://files.pythonhosted.org/packages/ce/15/9115fda2ca09f83128295d66abec9844b887e88dac1a31e63214b66f8025/aic_sdk-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0a2f7ac8dd9892a60245e5c704820419658471c3de8e9cb5ba530961bd32962c", size = 3247196, upload-time = "2026-05-29T13:41:12.162Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ad/f62c84fd4afcd7b4b70ebd597c2f6d6308b3434fe6df43e6df9492482d04/aic_sdk-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:9556eb5cc590295db48bc9010851f259cf5a56459e0526f54e8d2a6f66fbad2a", size = 2923470, upload-time = "2026-05-29T13:41:13.462Z" }, - { url = "https://files.pythonhosted.org/packages/12/c4/f5b0a5a0076929adbcfc2db7399ba9c7378f6d1ec5bacb4ad927d7d4b6d1/aic_sdk-2.3.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:4bf64f2e537e52926ecb93e0cafa4c9f483a47d7eeeb2a281343ee5e839ba0e5", size = 3876075, upload-time = "2026-05-29T13:41:15.043Z" }, - { url = "https://files.pythonhosted.org/packages/94/05/f18db2c350d5c881b41bf8a6676bd7462d6e4006c2d9465d8b175c61a052/aic_sdk-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:61e4b456b43106879372be05ae3576728d104eea7f58a8f118b097c501614fb9", size = 3472328, upload-time = "2026-05-29T13:41:16.451Z" }, - { url = "https://files.pythonhosted.org/packages/7f/4f/be9614fa9c904d752994688b00691a26a7255d3206d1d6557569a6e509fd/aic_sdk-2.3.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df75609a0410c93148a3d8c72b842559b062affc3db7cabe25a75f7b190adefb", size = 3430391, upload-time = "2026-05-29T13:41:18.3Z" }, - { url = "https://files.pythonhosted.org/packages/83/f6/fe501736ac6dbad47e5709d4e35d13bc2e9d84ea273ab763eada2f266e07/aic_sdk-2.3.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:676dff042be5148e38f572ad0dfb357f033774918e5b858330e4c66a6d3a814f", size = 3797907, upload-time = "2026-05-29T13:41:19.868Z" }, - { url = "https://files.pythonhosted.org/packages/79/08/f82e5a225733edc9146fdf103d40efef12b0abfd7f0bd3dc54799598f1d3/aic_sdk-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:af45a6375e3e774b8f4b480b9be86ddc21b9eaa12ba1bd11f21ba014c79346cb", size = 3249245, upload-time = "2026-05-29T13:41:21.223Z" }, - { url = "https://files.pythonhosted.org/packages/4e/f7/017c463e669e65e7abf8527eb28e130e10da73b36b4c516f7468ff709464/aic_sdk-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:a8dd2f3a79635cbbe7ef0610c7a619748f2b3e2af0df1a110d747e74b2753e41", size = 2925240, upload-time = "2026-05-29T13:41:22.476Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/43/2c/6cda275d3cdede8173bccf61b1474a35f532181c63c6bb26fff0c5230a9f/aic_sdk-2.5.0.tar.gz", hash = "sha256:1bdf60b35899a33491db52ccd1955552a0e9f015335f885a65664d8f4dce1128", size = 3359411, upload-time = "2026-06-23T12:11:54.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/6a/6cbff13cfd0ee106a5d39aff09ae537c6a09e715eefa4ee70bf92dfc1a63/aic_sdk-2.5.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d8c63554108b969b8193a790fc4f44407ec5ce14a16f496beb1bc47718f23156", size = 3967873, upload-time = "2026-06-23T12:11:18.073Z" }, + { url = "https://files.pythonhosted.org/packages/82/71/85d761fc4d6423790f9c70f89430c61ce49dc8b015eecbe672d0474caedb/aic_sdk-2.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fe504ed83d940f04db7390e4fd17d6cba0aa482732cc5f226f26baffbf9ac691", size = 3562678, upload-time = "2026-06-23T12:11:19.461Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d4/cd5b5d0dedea80f58153401814444282a077172fb7f01f04f4deb67a336c/aic_sdk-2.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eb2002db187dddff37c816d9ba7ce10f162caa6f39222466ad3316c1413a459a", size = 3498871, upload-time = "2026-06-23T12:11:21.036Z" }, + { url = "https://files.pythonhosted.org/packages/2d/b6/f919107540b53407a68dcc554d3963fe45ca8b8f821f4e8129894cbe6ae3/aic_sdk-2.5.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14d2334d68f160f3ce02f05b3d4849964434c448da49d44401d6d2ae8bdddb76", size = 3866355, upload-time = "2026-06-23T12:11:22.468Z" }, + { url = "https://files.pythonhosted.org/packages/10/4d/203326058a45b7eb5980f9c0ede8a8f9e7866f9f0b69a650d81dc2ba1ab7/aic_sdk-2.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:d912f2ca6ccb9736042da26c24c25798d4089a984133ff4ba0aaaf8a2da9d2f1", size = 3345081, upload-time = "2026-06-23T12:11:23.85Z" }, + { url = "https://files.pythonhosted.org/packages/63/db/c14341b499ef851e062e66203f2f92bf005a6fe736d28008ef2447ed8f92/aic_sdk-2.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:a90443308b612a24de2242e7767cb9637b0aeb7772098aa5b5035481dccd0576", size = 3019935, upload-time = "2026-06-23T12:11:25.456Z" }, + { url = "https://files.pythonhosted.org/packages/47/24/6b59cd335668b818a6724f2f4ec6113359549b5fcac8cb4d670ff314a793/aic_sdk-2.5.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:972ab9430ef4fc72e35b4fa001c05351c0197cf016fcd386e696b9f36915dbe4", size = 3967640, upload-time = "2026-06-23T12:11:26.956Z" }, + { url = "https://files.pythonhosted.org/packages/3d/82/8218aa03b42a757d3942bf932e61c67c8956c31096db0d5b8dfac0063a08/aic_sdk-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:494e1297d639cb3e04cf594d55a86103ed077b3ea547fb4cf5c4aa681bbe9296", size = 3561845, upload-time = "2026-06-23T12:11:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/47/48/b505e7366410438c48369a5821da012c5e9547faa05d1ed9bd6cd33d38f2/aic_sdk-2.5.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7c94198373ee8b8752269bdaf3a7b41cf7b2f70a6bd57b0cf9149f06b56f9975", size = 3497021, upload-time = "2026-06-23T12:11:29.723Z" }, + { url = "https://files.pythonhosted.org/packages/ed/51/e950f8e87e9f30f2bbd7efd3417aedc87f96f6d5188ab5c5b7c953c9a8b7/aic_sdk-2.5.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:648bedf940db39ca07f6b30c491acf941d6657bd4765158b2cefd4e324221c68", size = 3865453, upload-time = "2026-06-23T12:11:31.54Z" }, + { url = "https://files.pythonhosted.org/packages/92/95/517dfc22e2bcee480d09e1564cf2530495a2e74f9271494a6a560e11559e/aic_sdk-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:de75d25be985490dde5074822c23d5f20fb431ac1c7a58505c6eb32010072ee7", size = 3343261, upload-time = "2026-06-23T12:11:33.116Z" }, + { url = "https://files.pythonhosted.org/packages/09/99/c0a97efbaa15bd7ce3db08f858e43e1e6465bc8475bc0f10452c688a711e/aic_sdk-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:c09102254ff29e0cd9868eea6d15ae917984bde47e646535ebc614d7992622a9", size = 3017491, upload-time = "2026-06-23T12:11:34.432Z" }, + { url = "https://files.pythonhosted.org/packages/cb/22/6efc0f577eced3478f32871a6614114e1ad5450f8896d28ab83cc37fb3eb/aic_sdk-2.5.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:103dd9a6600eca9502556c7897407fa744a85a097bf9dc95155ae66eabe7a934", size = 3966802, upload-time = "2026-06-23T12:11:36.081Z" }, + { url = "https://files.pythonhosted.org/packages/c9/37/227520b23696510ccba3d53e590651e41e9b1f47ff6eb95221c8a3edb4e5/aic_sdk-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:05496be94b2267788a93bf0be5a59fb051cc460c016b71778a95602996a3c457", size = 3561454, upload-time = "2026-06-23T12:11:37.833Z" }, + { url = "https://files.pythonhosted.org/packages/69/06/9252b0832f8c43ac381e5451bd4fc3637d36e8f18e15d9864f9b735ea265/aic_sdk-2.5.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da7e5b7aba1cfb1d0055d0fc236ad9510a82a0a11ceb1401fb8faf01a5d5e770", size = 3496298, upload-time = "2026-06-23T12:11:39.231Z" }, + { url = "https://files.pythonhosted.org/packages/ba/93/ee5694ffd153c6bbf50ffb7e4046c892d25714bca072c55dc71f0ba0f9e1/aic_sdk-2.5.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77b042edb6a74daf2082ea07262c911fce297bf917c175d82ab43ac14ff56502", size = 3865018, upload-time = "2026-06-23T12:11:40.876Z" }, + { url = "https://files.pythonhosted.org/packages/79/54/a76d1ffbd074c2e0411dc959bbcbd9f22af97b6165b82f8f9b9248fb1df1/aic_sdk-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:8d2339cd892b34ddce4d5992c2588a87fb08763c6b3b841791f614cd3f82711e", size = 3342733, upload-time = "2026-06-23T12:11:42.192Z" }, + { url = "https://files.pythonhosted.org/packages/83/e9/6f3e64a5f219360f9ef416e0bfe9060baf54bea1b75d87970436f17ef6cb/aic_sdk-2.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:62c081c65c0483c8b64a7e4e98f003969e5570cc3b2d430a3adb0e390b6fbca2", size = 3016622, upload-time = "2026-06-23T12:11:43.792Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c9/ed35b4b281141bf6a407ba3bad109c8fdb99c14369e88303521eb2975199/aic_sdk-2.5.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1b3102a1daa722830ffd0925f6b611a1b4843d6906f823464ae51d0f788149b3", size = 3970201, upload-time = "2026-06-23T12:11:45.204Z" }, + { url = "https://files.pythonhosted.org/packages/65/36/fe4c50a4949c3fb1e95a2a4f61aff870d5006f7c46f108359f20d6436d2d/aic_sdk-2.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:253eef9ac4c3d6acf296034f549f1de1ef910ffa88ad22b2475a0ae9cb54d168", size = 3563387, upload-time = "2026-06-23T12:11:46.774Z" }, + { url = "https://files.pythonhosted.org/packages/d7/36/b2c174049862eb09d206169778725e8eca78e1db4d980e14b0b3d8f40c90/aic_sdk-2.5.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d85a7a874ea288cc33cc186811ee7fbe51e4234793148bbf84af91bffcd5664", size = 3499231, upload-time = "2026-06-23T12:11:48.411Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b5/728381d05d1ffb8bf4b66a2c8f814e0706996f3428ff033d921604cb0338/aic_sdk-2.5.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c2988d257d486f757a7748b15cf812443b57ffa55fa845e4f16e2456c8d16529", size = 3866684, upload-time = "2026-06-23T12:11:49.869Z" }, + { url = "https://files.pythonhosted.org/packages/02/d8/aaf8a8991798de0efa896f8cbcf0056b5fdc358649f20648b73d202d59ae/aic_sdk-2.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:54c7ba0095d287a6cf200979b937fd886f0a17cb2ce1864b946673fc3bdc84f5", size = 3346045, upload-time = "2026-06-23T12:11:51.282Z" }, + { url = "https://files.pythonhosted.org/packages/54/26/132d6d3c91f85dcacb6d6af5528d2acf4310b0ad66539bb958c56396215d/aic_sdk-2.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:703cdc653ce16127527b8b2318e74b093201703774a13810fbf6702649a189bd", size = 3019823, upload-time = "2026-06-23T12:11:52.861Z" }, ] [[package]] @@ -1945,9 +1945,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/42/3c/ff890b466eaba2b0f5e6bdfff025f8c75f41b8ffdc3dbc3d24ad261e764a/greenlet-3.5.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:73f78f9b9f0a5c06e5c946ba1e8e36f5114923b6be109ee618c54f079c3ea14f", size = 284764, upload-time = "2026-05-20T13:09:10.204Z" }, { url = "https://files.pythonhosted.org/packages/81/0e/5e5457be3d256918f6a4756f073548a3f0190836e2cc94aa6d0d617a940b/greenlet-3.5.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0cbed8bb44e23c5b199f888f4e4ce096b45ad9f25ff74a7ad0213875e936bb2", size = 603479, upload-time = "2026-05-20T14:00:04.757Z" }, { url = "https://files.pythonhosted.org/packages/6d/e1/f89a21d58d308298e6f275f13a1b472ed96c680b601a371b08be6a725989/greenlet-3.5.1-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a203a8bd0acb0701653d3bbb26e404854a68674139ed5cbb778830f42b09bb33", size = 615495, upload-time = "2026-05-20T14:05:40.87Z" }, - { url = "https://files.pythonhosted.org/packages/2c/f2/8fd452fd81adb9ec79c8275c1375702ab0fd6bee4952da12eaa09b9508d8/greenlet-3.5.1-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ebeb75c81211f5c702576cf81f315e77e23cfdb2c7c6fcb9dd143e6de35c360", size = 623515, upload-time = "2026-05-20T14:09:07.853Z" }, { url = "https://files.pythonhosted.org/packages/75/de/af6cef182862d2ccd6975440d21c9058a77c3f9b469abf94e322dfd2e0e3/greenlet-3.5.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a271fcd66c74615cda6a964fda3f304267a12e50a084472218a39bb0376f563", size = 614754, upload-time = "2026-05-20T13:14:24.947Z" }, - { url = "https://files.pythonhosted.org/packages/ec/bc/c318aa9f3ffc77320fddcee3d892be957b42e2ff947198d9450b004f3a38/greenlet-3.5.1-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:017a544f0385d441e88714160d089d6900ef46c9eff9d99b6715a5ef2d127747", size = 418439, upload-time = "2026-05-20T14:01:38.446Z" }, { url = "https://files.pythonhosted.org/packages/1a/c6/50e520283a9f19388a7326b05f9e8637e566003475eacaadad04f558c68d/greenlet-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ded7b068c7c31c1a8657d4fd42d886b3e051ae29f88b80c5ff9d502257b0f071", size = 1574097, upload-time = "2026-05-20T14:02:24.003Z" }, { url = "https://files.pythonhosted.org/packages/21/1c/13abd1f4860d987fa5e1170a01930d6e6cd40d328de487a3c9fdaff0ffd0/greenlet-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d0932b81d72f552ded9d810d00021b64d89f2195a91ce115b893f943b7a4ab3c", size = 1641058, upload-time = "2026-05-20T13:14:31.83Z" }, { url = "https://files.pythonhosted.org/packages/f5/56/5f332b7705545eac2dc01b4e9254d24a793f2656d55d5cc6b94ee59d22ae/greenlet-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:88e300d136eac057b2397aa1cfd7328b4c87c7eb66a09c7bc6a1292234db474e", size = 238089, upload-time = "2026-05-20T13:14:03.229Z" }, @@ -1955,9 +1953,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/37/4549f149c9797c21b32c2683c33522af22522099de128b2406672526d005/greenlet-3.5.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2", size = 286220, upload-time = "2026-05-20T13:07:28.463Z" }, { url = "https://files.pythonhosted.org/packages/38/ff/a4f436709716965eaab9f36ea7b906c8a927fbe32fb1372a2071d964f6b1/greenlet-3.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed", size = 601585, upload-time = "2026-05-20T14:00:06.141Z" }, { url = "https://files.pythonhosted.org/packages/65/ad/54bc3fcee3ad368a61b19b67d88117f7a8c29727bf71fffdeda81fbd946e/greenlet-3.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1072b4f9edcc1e192d9283a66a3e68d6b84c561de33a83d7858beb9ba1effe10", size = 614215, upload-time = "2026-05-20T14:05:42.675Z" }, - { url = "https://files.pythonhosted.org/packages/7c/6c/de5b1b388cd2d9fbdfeab324863daba37d54e6e233ddbefd70b385a8c591/greenlet-3.5.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:89101bfd5011e069be974903cb3a4e4523845e4ece2d62dcd8d358933c0ef249", size = 620094, upload-time = "2026-05-20T14:09:09.18Z" }, { url = "https://files.pythonhosted.org/packages/40/69/b91cda0647df839483201545913514c2827ebea5e5ccdf931842763bc127/greenlet-3.5.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b", size = 611358, upload-time = "2026-05-20T13:14:26.37Z" }, - { url = "https://files.pythonhosted.org/packages/4a/43/1204baffab8a6476464795a7ccf394a3248d4f22c9f87173a15b36b6d971/greenlet-3.5.1-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:e6cd99ea59dd5d89f0c956606571d79bfe6f68c9eb7f4a4083a41a7f1587edee", size = 422782, upload-time = "2026-05-20T14:01:39.597Z" }, { url = "https://files.pythonhosted.org/packages/59/90/3cf77e080350cd02fa307bb2abf05df48f4482c240275bbd2c203ba8bb1c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a5ea42a752d47a145eae922b605cd1634665ac3d5ec1e72402d5048e8d60d207", size = 1570475, upload-time = "2026-05-20T14:02:25.29Z" }, { url = "https://files.pythonhosted.org/packages/65/2c/18cece62045e74598c3c393f70dce4a63f56222015ba29a5d4eeb04f764c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5551170cf4f5ff5623e9af81323751979fee2c731e2287b61f73cd27257b823", size = 1635625, upload-time = "2026-05-20T13:14:34.027Z" }, { url = "https://files.pythonhosted.org/packages/30/f5/310d104ddf41eb5a70f4c268d22508dfb0c3c8e86fec152be34d0d2ed819/greenlet-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b", size = 238791, upload-time = "2026-05-20T13:10:39.018Z" }, @@ -1965,9 +1961,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/69/7f7e5372d998b81001899b1c0823c957aa413ba0f2662e65821611cc31e4/greenlet-3.5.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b", size = 285060, upload-time = "2026-05-20T13:08:51.899Z" }, { url = "https://files.pythonhosted.org/packages/b1/bf/387f9b6b865fd2ae0d0be09e0004827295a01b71be76ed350dd1e28a91a4/greenlet-3.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a", size = 604370, upload-time = "2026-05-20T14:00:07.492Z" }, { url = "https://files.pythonhosted.org/packages/32/f5/169ce3d4e4c67291bd18f8cbe0299c9f3e45102c7f1fb3c14780c93e4532/greenlet-3.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283", size = 616987, upload-time = "2026-05-20T14:05:44.237Z" }, - { url = "https://files.pythonhosted.org/packages/19/ba/c24110c55dffa55aa6e1d98b45310da33801aeba7686ff0190fe5d46fd32/greenlet-3.5.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d40a890035c0058cadbdc4af7569800fd28a0e527a0fdbb7b5f9418f176846ce", size = 622911, upload-time = "2026-05-20T14:09:10.598Z" }, { url = "https://files.pythonhosted.org/packages/ee/e5/7f2e41d5273be07e77560d61ea4e56485b4d6c316d2a84518c62d1364061/greenlet-3.5.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135", size = 613911, upload-time = "2026-05-20T13:14:27.539Z" }, - { url = "https://files.pythonhosted.org/packages/ec/7b/d20db2e8a5ad6c038702f3179b136f93f0a3d1a21a0c0777f3e470cdf4b2/greenlet-3.5.1-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:67821bb03e4e98664490edb787ff6af501194c29bbee0f5c1dfdcf1dc3d9d436", size = 425228, upload-time = "2026-05-20T14:01:40.837Z" }, { url = "https://files.pythonhosted.org/packages/c5/a4/fbdc67579b73615a1f91615e814303cc71e06128f7baaba87be79b8fb90c/greenlet-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd", size = 1570689, upload-time = "2026-05-20T14:02:27.225Z" }, { url = "https://files.pythonhosted.org/packages/e6/b4/77abbe35078be39718a46cd49caf16bceb35662f97a34101dca28aa98e47/greenlet-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1", size = 1635602, upload-time = "2026-05-20T13:14:36.344Z" }, { url = "https://files.pythonhosted.org/packages/37/f7/129f27ca700845b8ee8ca88ce7f43435a1239c2eddb7677fc938822762cf/greenlet-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9", size = 238683, upload-time = "2026-05-20T13:11:50.57Z" }, @@ -1975,9 +1969,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", size = 285579, upload-time = "2026-05-20T13:08:56.396Z" }, { url = "https://files.pythonhosted.org/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", size = 651106, upload-time = "2026-05-20T14:00:09.448Z" }, { url = "https://files.pythonhosted.org/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", size = 663478, upload-time = "2026-05-20T14:05:45.88Z" }, - { url = "https://files.pythonhosted.org/packages/c6/2d/2d80842910da44f78c286532d084b8a5c3717c844ae80ceb3858738ae89a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c09df69dc1712d131332054a858a3e5cca400967fa3a672e2324fbb0971448c", size = 667767, upload-time = "2026-05-20T14:09:12.15Z" }, { url = "https://files.pythonhosted.org/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", size = 660800, upload-time = "2026-05-20T13:14:29.129Z" }, - { url = "https://files.pythonhosted.org/packages/e9/d3/dad2eecedfbb1ed7050a20dcfae40c1442b74bc7423608be2c7e03ee7133/greenlet-3.5.1-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:a4764e0bfc6a4d114c865b32520805c16a990ef5f286a514413b05d5ecd6a23d", size = 470786, upload-time = "2026-05-20T14:01:42.064Z" }, { url = "https://files.pythonhosted.org/packages/7a/e0/6c71401a25cac7000261304e866a2f2cc04dc74810d40e2f118aa4799495/greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", size = 1617518, upload-time = "2026-05-20T14:02:28.662Z" }, { url = "https://files.pythonhosted.org/packages/41/26/c5c06643e8c0af9e7bf18e16cb51d0ab7625155f0392e1c9015d66d556cd/greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", size = 1681593, upload-time = "2026-05-20T13:14:39.417Z" }, { url = "https://files.pythonhosted.org/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", size = 239800, upload-time = "2026-05-20T13:09:30.128Z" }, @@ -1985,18 +1977,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/90/12/41bf27fde4d3605d3773ae57751eda182b8be2f5398011c041173b1d9534/greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", size = 293637, upload-time = "2026-05-20T13:12:35.529Z" }, { url = "https://files.pythonhosted.org/packages/44/44/ba14b23e9757707050c2f397d305bbcae62e5d7cad122f8b6baec5ae4a1f/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", size = 650840, upload-time = "2026-05-20T14:00:11.079Z" }, { url = "https://files.pythonhosted.org/packages/a8/37/5ddc2b686a6844f91abecef43411842426da2e1573f60b49ecf2547f4ae1/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", size = 656416, upload-time = "2026-05-20T14:05:47.118Z" }, - { url = "https://files.pythonhosted.org/packages/8c/46/5987dcd1a2570ba84f3b187536b2ca3ae97613387e57f5cfa99df068fe5e/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea37d5a157eb9493820d3792ac4ece28619a394391d2b9f2f78057d396ff0f0f", size = 656607, upload-time = "2026-05-20T14:09:13.949Z" }, { url = "https://files.pythonhosted.org/packages/e1/f0/d17510297c35a2992712f0bf84de3779749999f7d3d63aa1f09db7c62dbe/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", size = 654397, upload-time = "2026-05-20T13:14:30.696Z" }, - { url = "https://files.pythonhosted.org/packages/2c/c1/6da0a9ddcc29d7e51ef14883fa3dc1e53b3f4ffba00582106c7bf55da1d8/greenlet-3.5.1-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:8d8a23250ea3ec7b36de8fa4b541e9e2db3ee82915cc060ab0631609ad8b28de", size = 488287, upload-time = "2026-05-20T14:01:43.143Z" }, { url = "https://files.pythonhosted.org/packages/37/eb/147387705bb89092645b012586e7273cb5ed3c90ef7eaf3a69173eaf0209/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", size = 1614469, upload-time = "2026-05-20T14:02:30.192Z" }, { url = "https://files.pythonhosted.org/packages/a6/4e/37ee0da7732b7aa9896f17e15579a9df34b9fcb9dd494f0adfa749af6623/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", size = 1675115, upload-time = "2026-05-20T13:14:40.972Z" }, { url = "https://files.pythonhosted.org/packages/57/f3/97dfcf4a6eb5077f8a672234216fb5923eb89f2cab7081cb10b2cf75b605/greenlet-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", size = 245246, upload-time = "2026-05-20T13:12:22.646Z" }, { url = "https://files.pythonhosted.org/packages/5d/73/d7f72e34b582f694f4a9b248162db7b09cc458a259ba8f0c0bfa1a34ea7d/greenlet-3.5.1-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541", size = 285575, upload-time = "2026-05-20T13:12:07.043Z" }, { url = "https://files.pythonhosted.org/packages/df/59/fa9c6e87dc8ad27a95dabe2f29f372b733d05a8a67470f6c901ed9975655/greenlet-3.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de", size = 656428, upload-time = "2026-05-20T14:00:12.556Z" }, { url = "https://files.pythonhosted.org/packages/f6/f9/e753408871eaa61dfe35e619cfc67512b036fde99893685d50eea9e07146/greenlet-3.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64", size = 667064, upload-time = "2026-05-20T14:05:48.662Z" }, - { url = "https://files.pythonhosted.org/packages/dc/74/807a047255bf1e09303627c46dc043dca596b6958a354d904f32ab382005/greenlet-3.5.1-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:10a9a1c0bfbc93d41156ffcb90c75fbc05544054faf15dcc1fdf9765f8b607f0", size = 672962, upload-time = "2026-05-20T14:09:15.532Z" }, { url = "https://files.pythonhosted.org/packages/96/27/5565b5b40389f1c7753003a07e21892fda8660926787036d5bc0308b8113/greenlet-3.5.1-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5", size = 665697, upload-time = "2026-05-20T13:14:32.943Z" }, - { url = "https://files.pythonhosted.org/packages/76/32/19d4e13225193c29b13e308015223f7d75fd3d8623d49dd19040d2ce8ec1/greenlet-3.5.1-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:ef08c1567c78074b22d1a200183d52d04a14df447bf70bcbb6a3507a48e776fc", size = 476047, upload-time = "2026-05-20T14:01:44.39Z" }, { url = "https://files.pythonhosted.org/packages/cf/82/e7de4178c0c2d1c9a5a3be3cc0b33e46a85b3ee4a77c071bf7ad8600e079/greenlet-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368", size = 1621256, upload-time = "2026-05-20T14:02:31.91Z" }, { url = "https://files.pythonhosted.org/packages/00/10/f2dddcf7dacac17dfc68691809589adad06135eb28930429cf58a6467a2f/greenlet-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26", size = 1685956, upload-time = "2026-05-20T13:14:42.55Z" }, { url = "https://files.pythonhosted.org/packages/22/17/4a232b32133230ada52f70e9d7f5b65b0caef8772f01849bd8d149e7e4ca/greenlet-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab", size = 239802, upload-time = "2026-05-20T13:13:15.481Z" }, @@ -2004,9 +1992,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7a/57/816d9cff29119da3505b3d6a5e14a8af89006ac36f47f891ff293ee05af1/greenlet-3.5.1-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed", size = 293877, upload-time = "2026-05-20T13:10:19.078Z" }, { url = "https://files.pythonhosted.org/packages/23/a1/59b0a7c7d140ff1a75626680b9a9899b79a9176cab298b394968fb023295/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244", size = 655333, upload-time = "2026-05-20T14:00:14.758Z" }, { url = "https://files.pythonhosted.org/packages/72/1b/5efe127597625042218939d01855109f352779050768b670b52edcc16a6c/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c", size = 659443, upload-time = "2026-05-20T14:05:50.159Z" }, - { url = "https://files.pythonhosted.org/packages/c9/9d/1dcdf7b95ab3cf8c7b6d7277c18a5e167312f2b362ddfcc5d5e6d8d84b43/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a57b0d05a0448eed231d59c0ceb287dde984551e54cbc51ac2d4865712838e9c", size = 659998, upload-time = "2026-05-20T14:09:16.912Z" }, { url = "https://files.pythonhosted.org/packages/6c/6d/c404246ea4d22d097a7426d0efb5b781bd7eb67715f09e79001bd552ab18/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd", size = 658356, upload-time = "2026-05-20T13:14:35.091Z" }, - { url = "https://files.pythonhosted.org/packages/05/7e/c4959664fc231d587d66d8e81f2095e98056ba1954beafdcbe635e251052/greenlet-3.5.1-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:b0703c2cef53e01baec47f7a3868009913ad71ec678bbecb42a6f40895e4ce62", size = 494470, upload-time = "2026-05-20T14:01:45.611Z" }, { url = "https://files.pythonhosted.org/packages/51/02/f8ee37fb6d2219329f350af241c27fcf12df57e723d11f6fc6d3bacdadaa/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e", size = 1619216, upload-time = "2026-05-20T14:02:33.403Z" }, { url = "https://files.pythonhosted.org/packages/93/c5/3dc9475ace2c7a3680da12372cddd7f1ac874eb410a1ac48d3e9dab83782/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659", size = 1678427, upload-time = "2026-05-20T13:14:43.71Z" }, { url = "https://files.pythonhosted.org/packages/df/4e/750c15c317a41ffb36f0bf40b933e3d744a7dede61889f74443ea69690cf/greenlet-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e", size = 245225, upload-time = "2026-05-20T13:13:59.366Z" }, @@ -4599,7 +4585,7 @@ docs = [ [package.metadata] requires-dist = [ { name = "accelerate", marker = "extra == 'moondream'", specifier = "~=1.10.0" }, - { name = "aic-sdk", marker = "extra == 'aic'", specifier = "~=2.3.0" }, + { name = "aic-sdk", marker = "extra == 'aic'", specifier = "~=2.5.0" }, { name = "aiobotocore", marker = "extra == 'aws'", specifier = ">=3,<4" }, { name = "aiofiles", specifier = ">=24.1.0,<27" }, { name = "aiohttp", specifier = ">=3.11.12,<4" },