From 390d91c883fddf2cb8134cf0fed833450b6d9539 Mon Sep 17 00:00:00 2001 From: timo Date: Tue, 2 Jun 2026 17:56:43 +0200 Subject: [PATCH 01/11] feat(voxtral): Voxtral Realtime STT provider with concurrent streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `STT_PROVIDER=voxtral-realtime` backed by the vLLM Voxtral Realtime WebSocket API (`/v1/realtime`). - _writer — RMS VAD detects speech; sends opening commit at utterance start, streams 50 ms PCM16 chunks in real time, sends closing commit + commit(final: True) on silence. - _reader — concurrent asyncio task on the same WebSocket; reads transcription.delta → INTERIM, transcription.done → FINAL, loops across utterances without reconnecting. - utterance_start snapshot — captured at first delta so all events share the same BBB transcriptId; prevents later delta bursts from overwriting the visible caption. --- .env.example | 25 +- .github/workflows/publish-docker-image.yml | 1 + providers/__init__.py | 7 + providers/voxtral_realtime.py | 455 ++++++++++++++++++ tests/test_voxtral_agent.py | 510 +++++++++++++++++++++ uv.lock | 4 +- 6 files changed, 999 insertions(+), 3 deletions(-) create mode 100644 providers/voxtral_realtime.py create mode 100644 tests/test_voxtral_agent.py diff --git a/.env.example b/.env.example index 589d71c..bd21a5e 100644 --- a/.env.example +++ b/.env.example @@ -6,7 +6,7 @@ REDIS_HOST=127.0.0.1 REDIS_PORT=6789 REDIS_PASSWORD= -# STT Provider: "gladia" (default) or "openai" +# STT Provider: "gladia" (default), "openai" or "voxtral-realtime" # STT_PROVIDER=gladia # ============================================================================= @@ -76,3 +76,26 @@ GLADIA_TRANSLATION_LANG_MAP="de:de-DE,en:en-US,es:es-ES,fr:fr-FR,hi:hi-IN,it:it- # Base URL override — set this to use a compatible provider (e.g. a local Whisper server) #OPENAI_BASE_URL= + +# ============================================================================= +# --- Voxtral Realtime STT (STT_PROVIDER=voxtral-realtime) --- +# ============================================================================= + +#VOXTRAL_API_KEY= +#VOXTRAL_MODEL=mistralai/Voxtral-Mini-4B-Realtime-2602 +#VOXTRAL_BASE_URL= + +# Client-side VAD: RMS energy level above which a frame is considered speech (default: 500) +#VOXTRAL_SILENCE_THRESHOLD_RMS=500 + +# Seconds of silence after speech before the segment is flushed for transcription (default: 0.6) +#VOXTRAL_SILENCE_DURATION_S=0.6 + +# Maximum speech segment duration in seconds before a forced flush (default: 8.0) +#VOXTRAL_MAX_BUFFER_DURATION_S=8.0 + +# Target sample rate required by the model in Hz (default: 16000) +#VOXTRAL_TARGET_SAMPLE_RATE=16000 + +# Emit incremental transcription.delta events as interim captions (default: true) +#VOXTRAL_INTERIM_RESULTS=true diff --git a/.github/workflows/publish-docker-image.yml b/.github/workflows/publish-docker-image.yml index 135cb55..bc4c93d 100644 --- a/.github/workflows/publish-docker-image.yml +++ b/.github/workflows/publish-docker-image.yml @@ -5,6 +5,7 @@ on: push: branches: - development + - voxtral-* tags: - "v*.*.*" diff --git a/providers/__init__.py b/providers/__init__.py index 03d0b9f..11b467b 100644 --- a/providers/__init__.py +++ b/providers/__init__.py @@ -10,4 +10,11 @@ def create_agent(provider: str) -> BaseSttAgent: from providers.openai import OpenAiSttAgent, openai_config return OpenAiSttAgent(openai_config) + if provider == "voxtral-realtime": + from providers.voxtral_realtime import ( + VoxtralRealtimeSttAgent, + voxtral_realtime_config, + ) + + return VoxtralRealtimeSttAgent(voxtral_realtime_config) raise ValueError(f"Unknown STT provider: {provider}") diff --git a/providers/voxtral_realtime.py b/providers/voxtral_realtime.py new file mode 100644 index 0000000..40c315b --- /dev/null +++ b/providers/voxtral_realtime.py @@ -0,0 +1,455 @@ +"""STT provider for vLLM's Voxtral Realtime WebSocket API. + +vLLM's protocol differs from the OpenAI Realtime Transcription API in three ways: +- session.update: model is at the top level, not nested inside session.audio +- No server-side VAD: client must send input_audio_buffer.commit to trigger generation +- Response events: transcription.delta / transcription.done (not conversation.item.*) + +Audio must be PCM16, 16 kHz, mono, base64-encoded. +""" + +import asyncio +import base64 +import json +import logging +import os +import time +from dataclasses import dataclass, field + +import aiohttp +import numpy as np +from livekit import rtc +from livekit.agents import stt + +from providers.base import BaseSttAgent, BaseSttConfig + +_SILENCE_THRESHOLD_RMS = float(os.getenv("VOXTRAL_SILENCE_THRESHOLD_RMS", "500")) +_SILENCE_DURATION_S = float(os.getenv("VOXTRAL_SILENCE_DURATION_S", "0.6")) +_MAX_BUFFER_DURATION_S = float(os.getenv("VOXTRAL_MAX_BUFFER_DURATION_S", "8.0")) +_TARGET_SAMPLE_RATE = int(os.getenv("VOXTRAL_TARGET_SAMPLE_RATE", "16000")) + + +@dataclass +class VoxtralRealtimeConfig(BaseSttConfig): + api_key: str | None = field(default_factory=lambda: os.getenv("VOXTRAL_API_KEY")) + model: str = field( + default_factory=lambda: os.getenv( + "VOXTRAL_MODEL", "mistralai/Voxtral-Mini-4B-Realtime-2602" + ) + ) + base_url: str | None = field( + default_factory=lambda: os.getenv("VOXTRAL_BASE_URL", None) + ) + interim_results: bool = field( + default_factory=lambda: ( + os.getenv("VOXTRAL_INTERIM_RESULTS", "true").lower() != "false" + ) + ) + + +voxtral_realtime_config = VoxtralRealtimeConfig() + + +class VoxtralRealtimeSttAgent(BaseSttAgent): + def __init__(self, config: VoxtralRealtimeConfig): + super().__init__(config) + self._http_session: aiohttp.ClientSession | None = None + + def _get_http_session(self) -> aiohttp.ClientSession: + if self._http_session is None: + self._http_session = aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=None, connect=15) + ) + return self._http_session + + def _build_ws_url(self) -> str: + base = (self.config.base_url or "https://api.openai.com/v1").rstrip("/") + base = base.replace("https://", "wss://", 1).replace("http://", "ws://", 1) + return f"{base}/realtime?intent=transcription" + + def _create_stt_stream(self, locale: str) -> stt.SpeechStream: + raise NotImplementedError("VoxtralRealtime uses a custom pipeline") + + def _update_stream_locale(self, user_id: str, locale: str): + provider = self.participant_settings.get(user_id, {}).get( + "provider", "voxtral-realtime" + ) + self.stop_transcription_for_user(user_id) + self.start_transcription_for_user(user_id, locale, provider) + + def start_transcription_for_user(self, user_id: str, locale: str, provider: str): + settings = self.participant_settings.setdefault(user_id, {}) + settings["locale"] = locale + settings["provider"] = provider + + participant = self._find_participant(user_id) + if not participant: + logging.error( + f"Cannot start transcription, participant {user_id} not found." + ) + return + + track = self._find_audio_track(participant) + if not track: + logging.warning( + f"Won't start transcription yet, no audio track found for {user_id}." + ) + return + + if participant.identity in self.processing_info: + logging.debug( + f"Transcription already running for {participant.identity}, ignoring." + ) + return + + language = self._sanitize_locale(locale) + task = asyncio.create_task( + self._run_transcription_pipeline(participant, track, language) + ) + self.processing_info[participant.identity] = {"task": task} + logging.info( + f"Started Voxtral Realtime transcription for {participant.identity} ({locale})." + ) + + async def _cleanup(self): + await super()._cleanup() + if self._http_session: + await self._http_session.close() + self._http_session = None + + async def _run_transcription_pipeline( + self, + participant: rtc.RemoteParticipant, + track: rtc.Track, + language: str, + ): + ws_url = self._build_ws_url() + headers = {"Authorization": f"Bearer {self.config.api_key}"} + open_time = time.time() + retry_delay = 1.0 + + try: + while True: + audio_stream = rtc.AudioStream(track) + try: + async with self._get_http_session().ws_connect( + ws_url, headers=headers + ) as ws: + msg = await asyncio.wait_for(ws.receive(), timeout=10.0) + if msg.type != aiohttp.WSMsgType.TEXT: + logging.error( + "Voxtral WS: expected text for session.created" + ) + return + data = json.loads(msg.data) + if data.get("type") != "session.created": + logging.error( + f"Voxtral WS: unexpected first message: {data}" + ) + return + logging.info( + f"Voxtral WS session created for {participant.identity}" + ) + # Connection is healthy again; reset reconnect backoff. + retry_delay = 1.0 + + # vLLM expects model at top level of session.update + await ws.send_json( + {"type": "session.update", "model": self.config.model} + ) + + await self._vad_loop( + ws, audio_stream, participant, language, open_time + ) + return # clean exit — audio stream finished normally + + except asyncio.CancelledError: + raise + except aiohttp.ClientError as e: + logging.warning( + f"Voxtral WS connection lost for {participant.identity} " + f"({type(e).__name__}: {e}), reconnecting in {retry_delay:.0f}s" + ) + await asyncio.sleep(retry_delay) + retry_delay = min(retry_delay * 2, 30.0) + except Exception as e: + logging.error( + f"Voxtral Realtime error for {participant.identity}: {e}", + exc_info=True, + ) + return + finally: + await audio_stream.aclose() + + except asyncio.CancelledError: + logging.info( + f"Voxtral Realtime transcription for {participant.identity} cancelled." + ) + finally: + self.processing_info.pop(participant.identity, None) + + async def _vad_loop( + self, + ws: aiohttp.ClientWebSocketResponse, + audio_stream: rtc.AudioStream, + participant: rtc.RemoteParticipant, + language: str, + open_time: float, + ): + chunk_size = ( + _TARGET_SAMPLE_RATE // 20 * 2 + ) # 50 ms of int16 (matches official plugin) + + # Shared between _writer and _reader. asyncio is single-threaded so no + # lock is needed; the writer sets this synchronously before any await. + speech_start_time = 0.0 # set by writer when speech starts + + # ── Reader ──────────────────────────────────────────────────────────── + + async def _reader() -> None: + """Read transcription events for all utterances on this session. + + Runs concurrently with _writer so that transcription.delta events + emitted by the server while audio is still streaming are consumed + in real time rather than buffered and replayed after each commit. + """ + text = "" + # Snapshot speech_start_time at the first delta of each utterance. + # The writer may update speech_start_time for the next utterance + # before this utterance's transcription.done arrives; snapshotting + # here keeps all events for this utterance on the same transcriptId. + utterance_start: float | None = None + + while True: + try: + msg = await ws.receive() + except asyncio.CancelledError: + raise + except Exception as e: + logging.error( + f"Voxtral: reader error for {participant.identity}: {e}" + ) + break + + if msg.type in ( + aiohttp.WSMsgType.CLOSED, + aiohttp.WSMsgType.CLOSE, + aiohttp.WSMsgType.CLOSING, + ): + logging.debug(f"Voxtral: WS closed for {participant.identity}") + break + + if msg.type == aiohttp.WSMsgType.ERROR: + logging.warning( + f"Voxtral: WS error frame for {participant.identity}: " + f"{ws.exception()}" + ) + break + + if msg.type != aiohttp.WSMsgType.TEXT: + continue + + try: + data = json.loads(msg.data) + except (ValueError, TypeError) as e: + logging.warning( + f"Voxtral: ignoring malformed WS message for " + f"{participant.identity}: {e}" + ) + continue + msg_type = data.get("type") + + if msg_type == "transcription.delta": + if utterance_start is None: + utterance_start = speech_start_time + logging.debug( + f"Voxtral: first delta for {participant.identity} " + f"at t={time.time() - open_time:.3f}s " + f"(utterance_start={utterance_start:.3f}s)" + ) + text += data.get("delta", "") + if text and self.config.interim_results: + self.emit( + "interim_transcript", + participant=participant, + event=stt.SpeechEvent( + type=stt.SpeechEventType.INTERIM_TRANSCRIPT, + alternatives=[ + stt.SpeechData( + text=text, + language=language, + start_time=utterance_start, + end_time=time.time() - open_time, + ) + ], + ), + open_time=open_time, + ) + + elif msg_type == "transcription.done": + # Prefer accumulated delta text over done.text: the realtime + # API sends content via deltas; done.text may be empty or absent. + server_text = data.get("text", "").strip() + logging.debug( + f"Voxtral: transcription.done for {participant.identity} " + f"at t={time.time() - open_time:.3f}s — " + f"delta_text='{text[:60]}', server_text='{server_text[:60]}'" + ) + if server_text: + text = server_text + + if text: + self.emit( + "final_transcript", + participant=participant, + event=stt.SpeechEvent( + type=stt.SpeechEventType.FINAL_TRANSCRIPT, + alternatives=[ + stt.SpeechData( + text=text, + language=language, + start_time=utterance_start or speech_start_time, + end_time=time.time() - open_time, + ) + ], + ), + open_time=open_time, + ) + + # Reset for next utterance + text = "" + utterance_start = None + + elif msg_type == "error": + logging.error(f"Voxtral WS error event: {data}") + break + + # ── Writer (RMS speech detection, server-side streaming) ───────────── + # + # Protocol (verified empirically against vLLM Voxtral, see + # notes/progressive-transcription-investigation.md): + # 1. On speech start: send an OPENING commit to start a streaming + # transcription request. Without this the server only buffers and + # batch-transcribes on the closing commit (no live deltas). + # 2. During speech: append audio in real time. The server streams + # transcription.delta events back as audio arrives (first delta + # ~0.6s after speech start), which _reader emits as INTERIM. + # 3. On silence (or max-buffer): send closing commit + commit(final) + # to end the request. The server emits transcription.done, which + # _reader emits as FINAL. + # Each utterance is its own streaming request with its own + # speech_start_time, so consecutive utterances never collide on BBB's + # second-granularity transcriptId. + + async def _writer() -> None: + nonlocal speech_start_time + send_buffer_bytes = b"" + buffer_duration = 0.0 + silence_duration = 0.0 + was_speaking = False + + async def _append(data: bytes) -> None: + await ws.send_json( + { + "type": "input_audio_buffer.append", + "audio": base64.b64encode(data).decode(), + } + ) + + async def _open_stream() -> None: + """Send the opening commit that starts a streaming request.""" + await ws.send_json({"type": "input_audio_buffer.commit"}) + + async def _close_stream(tail_bytes: bytes) -> None: + """Flush remaining audio and close the utterance's request.""" + if tail_bytes: + await _append(tail_bytes) + await ws.send_json({"type": "input_audio_buffer.commit"}) + await ws.send_json({"type": "input_audio_buffer.commit", "final": True}) + + async for audio_event in audio_stream: + frame = audio_event.frame + samples = np.frombuffer(frame.data, dtype=np.int16) + rms = float(np.sqrt(np.mean(samples.astype(np.float32) ** 2))) + is_speaking = rms > _SILENCE_THRESHOLD_RMS + frame_duration = frame.samples_per_channel / frame.sample_rate + + if is_speaking: + if not was_speaking: + # New utterance: open a streaming request before any audio. + speech_start_time = time.time() - open_time + await _open_stream() + was_speaking = True + silence_duration = 0.0 + + send_buffer_bytes += _to_pcm16_16k(frame) + buffer_duration += frame_duration + + while len(send_buffer_bytes) >= chunk_size: + await _append(send_buffer_bytes[:chunk_size]) + send_buffer_bytes = send_buffer_bytes[chunk_size:] + + if buffer_duration >= _MAX_BUFFER_DURATION_S: + # Safety cap: close this utterance; the next speech frame + # reopens a fresh request with a new speech_start_time. + await _close_stream(send_buffer_bytes) + send_buffer_bytes = b"" + buffer_duration = 0.0 + silence_duration = 0.0 + was_speaking = False + + elif was_speaking: + # Trailing audio after speech, before silence threshold met. + send_buffer_bytes += _to_pcm16_16k(frame) + buffer_duration += frame_duration + silence_duration += frame_duration + + while len(send_buffer_bytes) >= chunk_size: + await _append(send_buffer_bytes[:chunk_size]) + send_buffer_bytes = send_buffer_bytes[chunk_size:] + + if silence_duration >= _SILENCE_DURATION_S: + await _close_stream(send_buffer_bytes) + send_buffer_bytes = b"" + buffer_duration = 0.0 + silence_duration = 0.0 + was_speaking = False + + if was_speaking: + await _close_stream(send_buffer_bytes) + + # ── Run reader and writer concurrently ──────────────────────────────── + + reader_task = asyncio.create_task(_reader()) + try: + await _writer() + finally: + reader_task.cancel() + try: + await reader_task + except asyncio.CancelledError: + pass + except Exception as e: + logging.error( + f"Voxtral: reader task crashed for {participant.identity}: {e}", + exc_info=True, + ) + + +def _to_pcm16_16k(frame: rtc.AudioFrame) -> bytes: + """Resample an AudioFrame to 16 kHz mono PCM16.""" + samples = np.frombuffer(frame.data, dtype=np.int16).astype(np.float32) + + if frame.num_channels > 1: + samples = samples.reshape(-1, frame.num_channels).mean(axis=1) + + if frame.sample_rate != _TARGET_SAMPLE_RATE: + n_orig = len(samples) + n_target = int(round(n_orig * _TARGET_SAMPLE_RATE / frame.sample_rate)) + samples = np.interp( + np.linspace(0, n_orig - 1, n_target), + np.arange(n_orig), + samples, + ) + + return np.clip(samples, -32768, 32767).astype(np.int16).tobytes() diff --git a/tests/test_voxtral_agent.py b/tests/test_voxtral_agent.py new file mode 100644 index 0000000..1ad7671 --- /dev/null +++ b/tests/test_voxtral_agent.py @@ -0,0 +1,510 @@ +import asyncio +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import aiohttp +import numpy as np +import pytest +from livekit import rtc +from livekit.agents import stt + +from providers.voxtral_realtime import ( + VoxtralRealtimeConfig, + VoxtralRealtimeSttAgent, + _SILENCE_THRESHOLD_RMS, + _to_pcm16_16k, +) + + +# ── Helpers ──────────────────────────────────────────────────────────────────── + + +def _make_config(**kwargs): + return VoxtralRealtimeConfig(api_key="test-key", **kwargs) + + +def _make_agent(**kwargs): + return VoxtralRealtimeSttAgent(_make_config(**kwargs)) + + +def _make_participant(identity, has_audio_track=True): + participant = MagicMock(spec=rtc.RemoteParticipant) + participant.identity = identity + pubs = {} + if has_audio_track: + mock_track = MagicMock() + mock_track.kind = rtc.TrackKind.KIND_AUDIO + pub = MagicMock() + pub.track = mock_track + pubs["audio"] = pub + participant.track_publications = pubs + return participant + + +def _make_agent_with_room(participants=None, **kwargs): + agent = _make_agent(**kwargs) + mock_room = MagicMock() + mock_room.remote_participants = participants or {} + agent.room = mock_room + return agent + + +def _text_ws_msg(data: dict) -> MagicMock: + msg = MagicMock() + msg.type = aiohttp.WSMsgType.TEXT + msg.data = json.dumps(data) + return msg + + +def _make_audio_frame( + amplitude: int = 0, + sample_rate: int = 16000, + num_channels: int = 1, + samples_per_channel: int = 160, +) -> MagicMock: + total = samples_per_channel * num_channels + samples = np.full(total, amplitude, dtype=np.int16) + frame = MagicMock() + frame.data = samples.tobytes() + frame.sample_rate = sample_rate + frame.samples_per_channel = samples_per_channel + frame.num_channels = num_channels + return frame + + +def _make_loud_frame(): + return _make_audio_frame(amplitude=int(_SILENCE_THRESHOLD_RMS * 2)) + + +# ── Config ───────────────────────────────────────────────────────────────────── + + +class TestVoxtralRealtimeConfig: + @pytest.fixture(autouse=True) + def _clean_env(self, monkeypatch): + for key in ["VOXTRAL_API_KEY", "VOXTRAL_MODEL", "VOXTRAL_BASE_URL", "VOXTRAL_INTERIM_RESULTS"]: + monkeypatch.delenv(key, raising=False) + + def test_default_model(self): + assert VoxtralRealtimeConfig().model == "mistralai/Voxtral-Mini-4B-Realtime-2602" + + def test_default_api_key_is_none(self): + assert VoxtralRealtimeConfig().api_key is None + + def test_default_base_url_is_none(self): + assert VoxtralRealtimeConfig().base_url is None + + def test_default_interim_results_is_true(self): + assert VoxtralRealtimeConfig().interim_results is True + + def test_interim_results_false_via_env(self, monkeypatch): + monkeypatch.setenv("VOXTRAL_INTERIM_RESULTS", "false") + assert VoxtralRealtimeConfig().interim_results is False + + def test_interim_results_true_via_env(self, monkeypatch): + monkeypatch.setenv("VOXTRAL_INTERIM_RESULTS", "true") + assert VoxtralRealtimeConfig().interim_results is True + + def test_custom_model_via_env(self, monkeypatch): + monkeypatch.setenv("VOXTRAL_MODEL", "my-custom-model") + assert VoxtralRealtimeConfig().model == "my-custom-model" + + def test_custom_api_key_via_env(self, monkeypatch): + monkeypatch.setenv("VOXTRAL_API_KEY", "sk-test-key") + assert VoxtralRealtimeConfig().api_key == "sk-test-key" + + def test_custom_base_url_via_env(self, monkeypatch): + monkeypatch.setenv("VOXTRAL_BASE_URL", "http://localhost:8000") + assert VoxtralRealtimeConfig().base_url == "http://localhost:8000" + + +# ── URL builder ──────────────────────────────────────────────────────────────── + + +class TestBuildWsUrl: + def test_default_url(self): + agent = _make_agent() + assert agent._build_ws_url() == "wss://api.openai.com/v1/realtime?intent=transcription" + + def test_custom_https_url_becomes_wss(self): + agent = _make_agent(base_url="https://my-server.example.com/v1") + assert agent._build_ws_url().startswith("wss://") + + def test_custom_http_url_becomes_ws(self): + agent = _make_agent(base_url="http://localhost:8000/v1") + assert agent._build_ws_url().startswith("ws://") + + def test_trailing_slash_in_base_url_is_stripped(self): + agent = _make_agent(base_url="https://my-server.example.com/v1/") + url = agent._build_ws_url() + assert "//realtime" not in url + + def test_custom_host_is_preserved(self): + agent = _make_agent(base_url="https://my-server.example.com/v1") + assert "my-server.example.com" in agent._build_ws_url() + + +# ── PCM conversion ───────────────────────────────────────────────────────────── + + +class TestToPcm16_16k: + def test_returns_bytes(self): + frame = _make_audio_frame() + assert isinstance(_to_pcm16_16k(frame), bytes) + + def test_mono_16k_passthrough_preserves_values(self): + frame = _make_audio_frame(amplitude=1000, sample_rate=16000, num_channels=1) + result = _to_pcm16_16k(frame) + samples = np.frombuffer(result, dtype=np.int16) + assert len(samples) == frame.samples_per_channel + assert all(s == 1000 for s in samples) + + def test_stereo_downmix_to_mono(self): + """Stereo frame with equal channels averages to same amplitude.""" + frame = _make_audio_frame(amplitude=1000, sample_rate=16000, num_channels=2) + result = _to_pcm16_16k(frame) + samples = np.frombuffer(result, dtype=np.int16) + assert len(samples) == frame.samples_per_channel + assert all(s == 1000 for s in samples) + + def test_resampling_from_48k_produces_correct_length(self): + frame = _make_audio_frame(amplitude=500, sample_rate=48000, num_channels=1) + result = _to_pcm16_16k(frame) + samples = np.frombuffer(result, dtype=np.int16) + expected = round(frame.samples_per_channel * 16000 / 48000) + assert len(samples) == expected + + def test_values_are_clipped_to_int16_range(self): + """numpy clip must keep all output values within ±32767.""" + frame = _make_audio_frame(amplitude=0, sample_rate=16000, num_channels=1) + # Override with float32 extremes stored as int16 (will saturate on cast) + raw = np.array([32767, -32768, 0], dtype=np.float32) + frame.data = raw.astype(np.int16).tobytes() + frame.samples_per_channel = 3 + result = _to_pcm16_16k(frame) + out = np.frombuffer(result, dtype=np.int16) + assert all(-32768 <= s <= 32767 for s in out) + + +# ── start_transcription_for_user ─────────────────────────────────────────────── + + +class TestStartTranscriptionForUser: + def test_participant_not_found_logs_error(self, caplog): + agent = _make_agent_with_room(participants={}) + with caplog.at_level("ERROR"): + agent.start_transcription_for_user("ghost_user", "en-US", "voxtral-realtime") + assert "ghost_user" in caplog.text + assert "ghost_user" not in agent.processing_info + + def test_no_audio_track_logs_warning(self, caplog): + participant = _make_participant("user_1", has_audio_track=False) + agent = _make_agent_with_room(participants={"p1": participant}) + with caplog.at_level("WARNING"): + agent.start_transcription_for_user("user_1", "en-US", "voxtral-realtime") + assert "user_1" in caplog.text + assert "user_1" not in agent.processing_info + + def test_already_running_is_ignored(self): + participant = _make_participant("user_1") + agent = _make_agent_with_room(participants={"p1": participant}) + existing_task = MagicMock() + agent.processing_info["user_1"] = {"task": existing_task} + + agent.start_transcription_for_user("user_1", "en-US", "voxtral-realtime") + + assert agent.processing_info["user_1"]["task"] is existing_task + + async def test_success_adds_task_to_processing_info(self): + participant = _make_participant("user_1") + agent = _make_agent_with_room(participants={"p1": participant}) + + with patch.object(agent, "_run_transcription_pipeline", new_callable=AsyncMock): + agent.start_transcription_for_user("user_1", "en-US", "voxtral-realtime") + + assert "user_1" in agent.processing_info + assert "task" in agent.processing_info["user_1"] + agent.processing_info.pop("user_1", None) + + async def test_locale_is_sanitized_to_language_code(self): + participant = _make_participant("user_1") + agent = _make_agent_with_room(participants={"p1": participant}) + + with patch.object(agent, "_run_transcription_pipeline", new_callable=AsyncMock) as mock_pipeline: + agent.start_transcription_for_user("user_1", "pt-BR", "voxtral-realtime") + await asyncio.sleep(0) + + # _run_transcription_pipeline(participant, track, language) — language must be "pt" + assert mock_pipeline.call_args[0][2] == "pt" + agent.processing_info.pop("user_1", None) + + async def test_settings_stored_on_start(self): + participant = _make_participant("user_1") + agent = _make_agent_with_room(participants={"p1": participant}) + + with patch.object(agent, "_run_transcription_pipeline", new_callable=AsyncMock): + agent.start_transcription_for_user("user_1", "de-DE", "voxtral-realtime") + + settings = agent.participant_settings.get("user_1", {}) + assert settings["locale"] == "de-DE" + assert settings["provider"] == "voxtral-realtime" + agent.processing_info.pop("user_1", None) + + +# ── _cleanup ─────────────────────────────────────────────────────────────────── + + +class TestCleanup: + async def test_closes_http_session_and_sets_none(self): + agent = _make_agent() + mock_session = AsyncMock() + agent._http_session = mock_session + + await agent._cleanup() + + mock_session.close.assert_called_once() + assert agent._http_session is None + + async def test_no_op_when_no_session(self): + agent = _make_agent() + assert agent._http_session is None + await agent._cleanup() # must not raise + + +# ── _run_transcription_pipeline — early exit paths ──────────────────────────── + + +class TestRunTranscriptionPipeline: + def _ws_context(self, first_message): + """Build an async context manager that yields a mock WS with one receive.""" + mock_ws = AsyncMock() + mock_ws.receive = AsyncMock(return_value=first_message) + mock_ws.send_json = AsyncMock() + cm = AsyncMock() + cm.__aenter__ = AsyncMock(return_value=mock_ws) + cm.__aexit__ = AsyncMock(return_value=False) + return cm + + def _mock_session(self, first_message): + session = MagicMock() + session.ws_connect = MagicMock(return_value=self._ws_context(first_message)) + return session + + async def test_exits_cleanly_on_non_text_first_message(self, caplog): + agent = _make_agent() + participant = MagicMock(spec=rtc.RemoteParticipant) + participant.identity = "user_1" + + binary_msg = MagicMock() + binary_msg.type = aiohttp.WSMsgType.BINARY + + agent._http_session = self._mock_session(binary_msg) + + mock_stream = AsyncMock() + mock_stream.__aiter__.return_value = iter([]) + mock_stream.aclose = AsyncMock() + + with patch("providers.voxtral_realtime.rtc.AudioStream", return_value=mock_stream): + await agent._run_transcription_pipeline(participant, MagicMock(), "en") + + assert "user_1" not in agent.processing_info + + async def test_exits_cleanly_on_wrong_first_message_type(self, caplog): + agent = _make_agent() + participant = MagicMock(spec=rtc.RemoteParticipant) + participant.identity = "user_1" + + agent._http_session = self._mock_session( + _text_ws_msg({"type": "session.error"}) # not "session.created" + ) + + mock_stream = AsyncMock() + mock_stream.__aiter__.return_value = iter([]) + mock_stream.aclose = AsyncMock() + + with patch("providers.voxtral_realtime.rtc.AudioStream", return_value=mock_stream): + await agent._run_transcription_pipeline(participant, MagicMock(), "en") + + assert "user_1" not in agent.processing_info + + +# ── _vad_loop — speech detection and final flush ────────────────────────────── + + +class TestVadLoop: + def _full_pipeline_setup(self, audio_frames, ws_messages): + """ + Return (agent, participant, mock_stream, mock_session) wired up so that + _run_transcription_pipeline can run end-to-end through the VAD loop. + ws_messages is a list appended after the mandatory session.created message. + """ + agent = _make_agent(interim_results=True) + participant = MagicMock(spec=rtc.RemoteParticipant) + participant.identity = "user_vad" + + all_ws = [_text_ws_msg({"type": "session.created"})] + ws_messages + + mock_ws = AsyncMock() + mock_ws.receive = AsyncMock(side_effect=all_ws) + # Use a real async function so that awaiting send_json actually yields + # to the event loop — this gives the concurrent _reader() task a chance + # to process incoming WS messages while the writer is still sending. + async def _send_json(_data): + await asyncio.sleep(0) + + mock_ws.send_json = _send_json + cm = AsyncMock() + cm.__aenter__ = AsyncMock(return_value=mock_ws) + cm.__aexit__ = AsyncMock(return_value=False) + + mock_session = MagicMock() + mock_session.ws_connect = MagicMock(return_value=cm) + agent._http_session = mock_session + + audio_events = [MagicMock(frame=f) for f in audio_frames] + mock_stream = AsyncMock() + mock_stream.__aiter__.return_value = iter(audio_events) + mock_stream.aclose = AsyncMock() + + return agent, participant, mock_stream + + async def test_silent_frames_do_not_trigger_flush(self): + """Only silence frames — no flush, no transcript event.""" + silence = _make_audio_frame(amplitude=0) + agent, participant, mock_stream = self._full_pipeline_setup( + audio_frames=[silence], + ws_messages=[], + ) + + emitted = [] + agent.on("final_transcript", lambda **kw: emitted.append(kw)) + + with patch("providers.voxtral_realtime.rtc.AudioStream", return_value=mock_stream): + await agent._run_transcription_pipeline(participant, MagicMock(), "en") + + assert emitted == [] + + async def test_loud_frame_followed_by_end_of_stream_emits_final(self): + """ + One loud frame followed by end-of-stream triggers the end-of-stream flush, + which should produce a final_transcript event. + """ + loud = _make_loud_frame() + agent, participant, mock_stream = self._full_pipeline_setup( + audio_frames=[loud], + ws_messages=[ + _text_ws_msg({"type": "transcription.delta", "delta": "hello"}), + _text_ws_msg({"type": "transcription.done", "text": "hello"}), + ], + ) + + emitted = [] + agent.on("final_transcript", lambda **kw: emitted.append(kw)) + + with patch("providers.voxtral_realtime.rtc.AudioStream", return_value=mock_stream): + await agent._run_transcription_pipeline(participant, MagicMock(), "en") + await asyncio.sleep(0) + + assert len(emitted) == 1 + event = emitted[0]["event"] + assert event.type == stt.SpeechEventType.FINAL_TRANSCRIPT + assert event.alternatives[0].text == "hello" + + async def test_interim_deltas_emitted_during_flush(self): + """Delta messages during flush are emitted as interim_transcript events.""" + loud = _make_loud_frame() + agent, participant, mock_stream = self._full_pipeline_setup( + audio_frames=[loud], + ws_messages=[ + _text_ws_msg({"type": "transcription.delta", "delta": "hi"}), + _text_ws_msg({"type": "transcription.done", "text": "hi"}), + ], + ) + + interim = [] + final = [] + agent.on("interim_transcript", lambda **kw: interim.append(kw)) + agent.on("final_transcript", lambda **kw: final.append(kw)) + + with patch("providers.voxtral_realtime.rtc.AudioStream", return_value=mock_stream): + await agent._run_transcription_pipeline(participant, MagicMock(), "en") + await asyncio.sleep(0) + + assert len(interim) >= 1 + assert interim[0]["event"].type == stt.SpeechEventType.INTERIM_TRANSCRIPT + assert len(final) == 1 + + async def test_two_utterances_emit_two_finals_with_independent_text(self): + """ + Two speech→silence cycles produce two independent FINAL transcripts. + + Guards the per-utterance reset: the delta accumulator and utterance_start + are cleared on transcription.done so the second utterance does not inherit + the first utterance's text (no "helloworld" bleed). + """ + loud = _make_loud_frame() + # A single silence frame long enough to cross _SILENCE_DURATION_S (0.6 s), + # flushing the utterance: 9600 samples / 16000 Hz = 0.6 s. + silence = _make_audio_frame(amplitude=0, samples_per_channel=9600) + agent, participant, mock_stream = self._full_pipeline_setup( + audio_frames=[loud, silence, loud, silence], + ws_messages=[ + _text_ws_msg({"type": "transcription.delta", "delta": "hello"}), + _text_ws_msg({"type": "transcription.done", "text": "hello"}), + _text_ws_msg({"type": "transcription.delta", "delta": "world"}), + _text_ws_msg({"type": "transcription.done", "text": "world"}), + ], + ) + + interim = [] + final = [] + agent.on("interim_transcript", lambda **kw: interim.append(kw)) + agent.on("final_transcript", lambda **kw: final.append(kw)) + + with patch("providers.voxtral_realtime.rtc.AudioStream", return_value=mock_stream): + await agent._run_transcription_pipeline(participant, MagicMock(), "en") + await asyncio.sleep(0) + + final_texts = [kw["event"].alternatives[0].text for kw in final] + assert final_texts == ["hello", "world"] + + # The second utterance's interim must not carry the first's text. + second_interim = [ + kw["event"].alternatives[0].text + for kw in interim + if kw["event"].alternatives[0].text.startswith("world") + ] + assert second_interim, "expected an interim for the second utterance" + assert not any(t.startswith("hello") for t in second_interim) + + async def test_all_events_of_an_utterance_share_start_time(self): + """ + The interim and final transcripts for one utterance carry the same + start_time — the snapshot taken at the first delta, so every event lands + on the same BBB transcriptId rather than drifting with later deltas. + """ + loud = _make_loud_frame() + agent, participant, mock_stream = self._full_pipeline_setup( + audio_frames=[loud], + ws_messages=[ + _text_ws_msg({"type": "transcription.delta", "delta": "one "}), + _text_ws_msg({"type": "transcription.delta", "delta": "two"}), + _text_ws_msg({"type": "transcription.done", "text": "one two"}), + ], + ) + + interim = [] + final = [] + agent.on("interim_transcript", lambda **kw: interim.append(kw)) + agent.on("final_transcript", lambda **kw: final.append(kw)) + + with patch("providers.voxtral_realtime.rtc.AudioStream", return_value=mock_stream): + await agent._run_transcription_pipeline(participant, MagicMock(), "en") + await asyncio.sleep(0) + + assert len(final) == 1 + start_times = {kw["event"].alternatives[0].start_time for kw in interim} + start_times.add(final[0]["event"].alternatives[0].start_time) + assert len(start_times) == 1, ( + f"all events for one utterance must share start_time, got {start_times}" + ) diff --git a/uv.lock b/uv.lock index 23cab9b..43305a8 100644 --- a/uv.lock +++ b/uv.lock @@ -241,10 +241,10 @@ dependencies = [ [package.dev-dependencies] dev = [ - { name = "ruff" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, + { name = "ruff" }, ] [package.metadata] @@ -257,10 +257,10 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ - { name = "ruff", specifier = ">=0.15.4" }, { name = "pytest", specifier = ">=9.0.2" }, { name = "pytest-asyncio", specifier = ">=1.3.0" }, { name = "pytest-cov", specifier = ">=7.0.0" }, + { name = "ruff", specifier = ">=0.15.4" }, ] [[package]] From 1161b80a3939652693c446aeb6a61a628b0b6146 Mon Sep 17 00:00:00 2001 From: timo Date: Wed, 10 Jun 2026 18:49:20 +0200 Subject: [PATCH 02/11] feat(voxtral): replace RMS VAD with Silero neural VAD on Python 3.11 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fixed RMS energy threshold cannot distinguish speech from background noise and clips quietly-spoken words. Replace it with Silero VAD, which uses a neural model and is already bundled in livekit-agents[silero]. Python 3.11 is required because onnxruntime ≥ 1.24 dropped Python 3.10 wheels. --- .env.example | 12 +- .github/workflows/lint.yml | 2 +- .github/workflows/tests.yml | 4 +- .python-version | 2 +- Dockerfile | 2 +- providers/voxtral_realtime.py | 261 +++++++++++++++--------- pyproject.toml | 4 +- tests/test_voxtral_agent.py | 230 ++++++++++++++++++++- uv.lock | 367 ++++++---------------------------- 9 files changed, 471 insertions(+), 413 deletions(-) diff --git a/.env.example b/.env.example index bd21a5e..600d65b 100644 --- a/.env.example +++ b/.env.example @@ -85,11 +85,15 @@ GLADIA_TRANSLATION_LANG_MAP="de:de-DE,en:en-US,es:es-ES,fr:fr-FR,hi:hi-IN,it:it- #VOXTRAL_MODEL=mistralai/Voxtral-Mini-4B-Realtime-2602 #VOXTRAL_BASE_URL= -# Client-side VAD: RMS energy level above which a frame is considered speech (default: 500) -#VOXTRAL_SILENCE_THRESHOLD_RMS=500 +# Silero VAD: minimum silence duration (s) before end-of-speech fires (default: 0.6) +#VOXTRAL_VAD_MIN_SILENCE_S=0.6 -# Seconds of silence after speech before the segment is flushed for transcription (default: 0.6) -#VOXTRAL_SILENCE_DURATION_S=0.6 +# Silero VAD: speech probability activation threshold 0–1 (default: 0.5) +#VOXTRAL_VAD_ACTIVATION_THRESHOLD=0.5 + +# Rolling audio pre-roll kept while idle and replayed when speech starts, so the +# word onset preceding Silero's detection is not lost (default: 0.5) +#VOXTRAL_VAD_PREROLL_S=0.5 # Maximum speech segment duration in seconds before a forced flush (default: 8.0) #VOXTRAL_MAX_BUFFER_DURATION_S=8.0 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 04f84d8..15c63fa 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -28,7 +28,7 @@ jobs: - uses: actions/setup-python@v6 with: - python-version: "3.10" + python-version: "3.11" - name: Install the project run: uv sync --all-extras --dev diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ce9b35b..5af0c4c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -24,7 +24,7 @@ jobs: - uses: actions/setup-python@v6 with: - python-version: "3.10" + python-version: "3.11" - name: Install dependencies run: uv sync --group dev @@ -54,7 +54,7 @@ jobs: - uses: actions/setup-python@v6 with: - python-version: "3.10" + python-version: "3.11" - name: Install dependencies if: ${{ env.GLADIA_API_KEY != '' }} diff --git a/.python-version b/.python-version index c8cfe39..2c07333 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.10 +3.11 diff --git a/Dockerfile b/Dockerfile index b2f6a8b..d551f95 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.10-slim +FROM python:3.11-slim # Use uv instead of pip, see https://github.com/astral-sh/uv RUN pip install --no-cache-dir uv==0.10.4 diff --git a/providers/voxtral_realtime.py b/providers/voxtral_realtime.py index 40c315b..fb8621a 100644 --- a/providers/voxtral_realtime.py +++ b/providers/voxtral_realtime.py @@ -14,19 +14,26 @@ import logging import os import time +from collections import deque from dataclasses import dataclass, field import aiohttp import numpy as np from livekit import rtc from livekit.agents import stt +from livekit.agents import vad as agents_vad +from livekit.plugins import silero from providers.base import BaseSttAgent, BaseSttConfig -_SILENCE_THRESHOLD_RMS = float(os.getenv("VOXTRAL_SILENCE_THRESHOLD_RMS", "500")) -_SILENCE_DURATION_S = float(os.getenv("VOXTRAL_SILENCE_DURATION_S", "0.6")) _MAX_BUFFER_DURATION_S = float(os.getenv("VOXTRAL_MAX_BUFFER_DURATION_S", "8.0")) _TARGET_SAMPLE_RATE = int(os.getenv("VOXTRAL_TARGET_SAMPLE_RATE", "16000")) +# Silero VAD parameters — replace the old RMS threshold and silence duration +_VAD_MIN_SILENCE_S = float(os.getenv("VOXTRAL_VAD_MIN_SILENCE_S", "0.6")) +_VAD_ACTIVATION_THRESHOLD = float(os.getenv("VOXTRAL_VAD_ACTIVATION_THRESHOLD", "0.5")) +# Rolling pre-roll kept while idle so the word onset preceding Silero's +# START_OF_SPEECH (its prefix padding) is sent once a streaming request opens. +_VAD_PREROLL_S = float(os.getenv("VOXTRAL_VAD_PREROLL_S", "0.5")) @dataclass @@ -51,9 +58,17 @@ class VoxtralRealtimeConfig(BaseSttConfig): class VoxtralRealtimeSttAgent(BaseSttAgent): - def __init__(self, config: VoxtralRealtimeConfig): + def __init__( + self, + config: VoxtralRealtimeConfig, + vad: agents_vad.VAD | None = None, + ): super().__init__(config) self._http_session: aiohttp.ClientSession | None = None + self._vad: agents_vad.VAD = vad or silero.VAD.load( + min_silence_duration=_VAD_MIN_SILENCE_S, + activation_threshold=_VAD_ACTIVATION_THRESHOLD, + ) def _get_http_session(self) -> aiohttp.ClientSession: if self._http_session is None: @@ -200,9 +215,14 @@ async def _vad_loop( _TARGET_SAMPLE_RATE // 20 * 2 ) # 50 ms of int16 (matches official plugin) - # Shared between _writer and _reader. asyncio is single-threaded so no - # lock is needed; the writer sets this synchronously before any await. - speech_start_time = 0.0 # set by writer when speech starts + # Per-segment start times, pushed by _writer._open() and popped by + # _reader at each segment's first delta. The server processes segments + # strictly in order on one connection, so FIFO pairing is exact. This + # gives every segment a distinct start_time — and therefore a distinct + # BBB transcriptId — including max-buffer splits of one long utterance, + # where Silero fires no new START_OF_SPEECH (a shared "speech start" + # variable would collide and make segment 2 overwrite segment 1). + segment_starts: deque[float] = deque() # ── Reader ──────────────────────────────────────────────────────────── @@ -214,10 +234,6 @@ async def _reader() -> None: in real time rather than buffered and replayed after each commit. """ text = "" - # Snapshot speech_start_time at the first delta of each utterance. - # The writer may update speech_start_time for the next utterance - # before this utterance's transcription.done arrives; snapshotting - # here keeps all events for this utterance on the same transcriptId. utterance_start: float | None = None while True: @@ -261,7 +277,12 @@ async def _reader() -> None: if msg_type == "transcription.delta": if utterance_start is None: - utterance_start = speech_start_time + # Pair this segment with the start time its opener pushed. + utterance_start = ( + segment_starts.popleft() + if segment_starts + else time.time() - open_time + ) logging.debug( f"Voxtral: first delta for {participant.identity} " f"at t={time.time() - open_time:.3f}s " @@ -298,6 +319,15 @@ async def _reader() -> None: if server_text: text = server_text + if utterance_start is None: + # Zero-delta segment: consume its queued start anyway so + # later segments stay paired with their own openers. + utterance_start = ( + segment_starts.popleft() + if segment_starts + else time.time() - open_time + ) + if text: self.emit( "final_transcript", @@ -308,7 +338,7 @@ async def _reader() -> None: stt.SpeechData( text=text, language=language, - start_time=utterance_start or speech_start_time, + start_time=utterance_start, end_time=time.time() - open_time, ) ], @@ -324,29 +354,69 @@ async def _reader() -> None: logging.error(f"Voxtral WS error event: {data}") break - # ── Writer (RMS speech detection, server-side streaming) ───────────── + # ── VAD task + Writer (Silero VAD, server-side streaming) ──────────── # # Protocol (verified empirically against vLLM Voxtral, see # notes/progressive-transcription-investigation.md): - # 1. On speech start: send an OPENING commit to start a streaming - # transcription request. Without this the server only buffers and - # batch-transcribes on the closing commit (no live deltas). - # 2. During speech: append audio in real time. The server streams - # transcription.delta events back as audio arrives (first delta - # ~0.6s after speech start), which _reader emits as INTERIM. - # 3. On silence (or max-buffer): send closing commit + commit(final) - # to end the request. The server emits transcription.done, which + # 1. On speech start (Silero START_OF_SPEECH): send an OPENING commit + # to start a streaming request. Without this the server only buffers + # and batch-transcribes on the closing commit (no live deltas). + # 2. While a request is open, audio is appended in real time. The + # server streams transcription.delta events back as audio arrives + # (first delta ~0.6 s after speech start), which _reader emits as + # INTERIM. While idle, only a bounded pre-roll is kept locally. + # 3. On speech end (Silero END_OF_SPEECH or max-buffer): send closing + # commit + commit(final). The server emits transcription.done, which # _reader emits as FINAL. - # Each utterance is its own streaming request with its own - # speech_start_time, so consecutive utterances never collide on BBB's - # second-granularity transcriptId. + # Each segment is its own streaming request with its own entry in + # segment_starts, so consecutive segments — including max-buffer splits + # of one long utterance — never collide on BBB's second-granularity + # transcriptId. + # + # Silero VAD only controls WHEN commits are sent; audio filtering is not + # needed and was the root cause of our earlier failed attempt. + + # Shared between _vad_task and _writer (asyncio single-threaded, no locks) + is_in_speech = False + commit_event = asyncio.Event() # set by _vad_task on END_OF_SPEECH + + vad_stream = self._vad.stream() + + async def _vad_task() -> None: + nonlocal is_in_speech + try: + async for ev in vad_stream: + if ev.type == agents_vad.VADEventType.START_OF_SPEECH: + is_in_speech = True + logging.debug( + f"Voxtral: speech start for {participant.identity}" + ) + elif ev.type == agents_vad.VADEventType.END_OF_SPEECH: + is_in_speech = False + commit_event.set() + logging.debug( + f"Voxtral: speech end for {participant.identity}" + ) + except asyncio.CancelledError: + raise + except Exception as e: + # A dead VAD task would silently freeze is_in_speech and stop all + # commits; surface it rather than degrade to max-buffer-only. + logging.error( + f"Voxtral: VAD task failed for {participant.identity}: {e}", + exc_info=True, + ) async def _writer() -> None: - nonlocal speech_start_time - send_buffer_bytes = b"" - buffer_duration = 0.0 - silence_duration = 0.0 - was_speaking = False + # `stream_open` is owned exclusively by _writer; `is_in_speech` is + # owned exclusively by _vad_task. Keeping them separate is what lets a + # max-buffer split mid-utterance reopen immediately on the next frame + # (we never clobber the VAD's view of whether speech is ongoing). + preroll_max = int(_VAD_PREROLL_S * _TARGET_SAMPLE_RATE) * 2 # int16 bytes + preroll = bytearray() # recent audio captured while no request is open + pending = b"" # audio buffered for chunked append while open + open_secs = 0.0 # duration of the current open segment + stream_open = False async def _append(data: bytes) -> None: await ws.send_json( @@ -356,84 +426,91 @@ async def _append(data: bytes) -> None: } ) - async def _open_stream() -> None: - """Send the opening commit that starts a streaming request.""" + async def _flush_pending() -> None: + nonlocal pending + while len(pending) >= chunk_size: + await _append(pending[:chunk_size]) + pending = pending[chunk_size:] + + async def _open() -> None: + """Open a streaming request and replay the captured lead-in.""" + nonlocal stream_open, pending, open_secs + commit_event.clear() # discard any stale END from a prior segment + # Record this segment's start (backdated by the replayed pre-roll) + # for the reader to pair with the segment's transcription events. + preroll_secs = len(preroll) / (_TARGET_SAMPLE_RATE * 2) + segment_starts.append(time.time() - open_time - preroll_secs) await ws.send_json({"type": "input_audio_buffer.commit"}) - - async def _close_stream(tail_bytes: bytes) -> None: + stream_open = True + open_secs = 0.0 + if preroll: + pending += bytes(preroll) + preroll.clear() + await _flush_pending() + + async def _close() -> None: """Flush remaining audio and close the utterance's request.""" - if tail_bytes: - await _append(tail_bytes) + nonlocal stream_open, pending + if pending: + await _append(pending) + pending = b"" await ws.send_json({"type": "input_audio_buffer.commit"}) await ws.send_json({"type": "input_audio_buffer.commit", "final": True}) + stream_open = False + preroll.clear() # committed; pre-roll only seeds the next onset async for audio_event in audio_stream: frame = audio_event.frame - samples = np.frombuffer(frame.data, dtype=np.int16) - rms = float(np.sqrt(np.mean(samples.astype(np.float32) ** 2))) - is_speaking = rms > _SILENCE_THRESHOLD_RMS frame_duration = frame.samples_per_channel / frame.sample_rate - if is_speaking: - if not was_speaking: - # New utterance: open a streaming request before any audio. - speech_start_time = time.time() - open_time - await _open_stream() - was_speaking = True - silence_duration = 0.0 - - send_buffer_bytes += _to_pcm16_16k(frame) - buffer_duration += frame_duration - - while len(send_buffer_bytes) >= chunk_size: - await _append(send_buffer_bytes[:chunk_size]) - send_buffer_bytes = send_buffer_bytes[chunk_size:] - - if buffer_duration >= _MAX_BUFFER_DURATION_S: - # Safety cap: close this utterance; the next speech frame - # reopens a fresh request with a new speech_start_time. - await _close_stream(send_buffer_bytes) - send_buffer_bytes = b"" - buffer_duration = 0.0 - silence_duration = 0.0 - was_speaking = False - - elif was_speaking: - # Trailing audio after speech, before silence threshold met. - send_buffer_bytes += _to_pcm16_16k(frame) - buffer_duration += frame_duration - silence_duration += frame_duration - - while len(send_buffer_bytes) >= chunk_size: - await _append(send_buffer_bytes[:chunk_size]) - send_buffer_bytes = send_buffer_bytes[chunk_size:] - - if silence_duration >= _SILENCE_DURATION_S: - await _close_stream(send_buffer_bytes) - send_buffer_bytes = b"" - buffer_duration = 0.0 - silence_duration = 0.0 - was_speaking = False - - if was_speaking: - await _close_stream(send_buffer_bytes) - - # ── Run reader and writer concurrently ──────────────────────────────── + # Feed the VAD, then yield once so _vad_task is scheduled. In + # production the network awaits below also yield; this keeps the + # interleave deterministic and lets tests drive a sync frame + # iterator without starving the VAD task. + vad_stream.push_frame(frame) + await asyncio.sleep(0) + + resampled = _to_pcm16_16k(frame) + + # Open a request as soon as the VAD reports speech. + if is_in_speech and not stream_open: + await _open() + + if stream_open: + pending += resampled + open_secs += frame_duration + await _flush_pending() + else: + # Idle: keep only a bounded rolling pre-roll; send nothing. + preroll += resampled + if len(preroll) > preroll_max: + del preroll[:-preroll_max] + + # Close on end-of-speech (Silero) or the max-buffer safety cap. + # is_in_speech is deliberately left untouched: if the speaker is + # still talking past the cap, the next frame reopens immediately. + if stream_open and ( + commit_event.is_set() or open_secs >= _MAX_BUFFER_DURATION_S + ): + commit_event.clear() + await _close() + + # End of stream: flush any open request. + if stream_open: + await _close() + vad_stream.end_input() + + # ── Run all three tasks concurrently ────────────────────────────────── reader_task = asyncio.create_task(_reader()) + vad_task = asyncio.create_task(_vad_task()) try: await _writer() finally: reader_task.cancel() - try: - await reader_task - except asyncio.CancelledError: - pass - except Exception as e: - logging.error( - f"Voxtral: reader task crashed for {participant.identity}: {e}", - exc_info=True, - ) + vad_task.cancel() + await asyncio.gather(reader_task, vad_task, return_exceptions=True) + await vad_stream.aclose() def _to_pcm16_16k(frame: rtc.AudioFrame) -> bytes: diff --git a/pyproject.toml b/pyproject.toml index 24f7343..05ff73e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,9 +3,9 @@ name = "bbb-livekit-stt" version = "0.2.0" description = "Provides STT for BigBlueButton meetings using LiveKit as their audio bridge." readme = "README.md" -requires-python = ">=3.10" +requires-python = ">=3.11" dependencies = [ - "livekit-agents[gladia]~=1.4", + "livekit-agents[gladia,silero]~=1.4", "python-dotenv~=1.1.1", "redis~=6.4.0", "nest-asyncio~=1.6.0", diff --git a/tests/test_voxtral_agent.py b/tests/test_voxtral_agent.py index 1ad7671..2de3f54 100644 --- a/tests/test_voxtral_agent.py +++ b/tests/test_voxtral_agent.py @@ -8,10 +8,11 @@ from livekit import rtc from livekit.agents import stt +from livekit.agents import vad as agents_vad + from providers.voxtral_realtime import ( VoxtralRealtimeConfig, VoxtralRealtimeSttAgent, - _SILENCE_THRESHOLD_RMS, _to_pcm16_16k, ) @@ -19,12 +20,27 @@ # ── Helpers ──────────────────────────────────────────────────────────────────── +def _make_mock_vad(vad_events=None): + """Return a mock VAD that emits the given VADEvent sequence.""" + mock_vad = MagicMock() + # AsyncMock with __aiter__.return_value is the correct pattern for async-for + # (same as AudioStream mocking in other test files). + mock_vad_stream = AsyncMock() + mock_vad_stream.push_frame = MagicMock() + mock_vad_stream.end_input = MagicMock() + mock_vad_stream.aclose = AsyncMock() + events = vad_events or [] + mock_vad_stream.__aiter__.return_value = iter(events) + mock_vad.stream.return_value = mock_vad_stream + return mock_vad + + def _make_config(**kwargs): return VoxtralRealtimeConfig(api_key="test-key", **kwargs) -def _make_agent(**kwargs): - return VoxtralRealtimeSttAgent(_make_config(**kwargs)) +def _make_agent(vad_events=None, **kwargs): + return VoxtralRealtimeSttAgent(_make_config(**kwargs), vad=_make_mock_vad(vad_events)) def _make_participant(identity, has_audio_track=True): @@ -73,7 +89,8 @@ def _make_audio_frame( def _make_loud_frame(): - return _make_audio_frame(amplitude=int(_SILENCE_THRESHOLD_RMS * 2)) + # Amplitude value is irrelevant; speech detection is now Silero-based (mocked in tests) + return _make_audio_frame(amplitude=1000) # ── Config ───────────────────────────────────────────────────────────────────── @@ -331,14 +348,27 @@ async def test_exits_cleanly_on_wrong_first_message_type(self, caplog): # ── _vad_loop — speech detection and final flush ────────────────────────────── +def _make_vad_event(event_type: agents_vad.VADEventType) -> MagicMock: + ev = MagicMock() + ev.type = event_type + return ev + + class TestVadLoop: - def _full_pipeline_setup(self, audio_frames, ws_messages): + def _full_pipeline_setup(self, audio_frames, ws_messages, vad_events=None): """ - Return (agent, participant, mock_stream, mock_session) wired up so that + Return (agent, participant, mock_stream) wired up so that _run_transcription_pipeline can run end-to-end through the VAD loop. ws_messages is a list appended after the mandatory session.created message. + vad_events: Silero VAD events emitted by the mock; defaults to + [START_OF_SPEECH, END_OF_SPEECH] so a commit fires. """ - agent = _make_agent(interim_results=True) + if vad_events is None: + vad_events = [ + _make_vad_event(agents_vad.VADEventType.START_OF_SPEECH), + _make_vad_event(agents_vad.VADEventType.END_OF_SPEECH), + ] + agent = _make_agent(interim_results=True, vad_events=vad_events) participant = MagicMock(spec=rtc.RemoteParticipant) participant.identity = "user_vad" @@ -369,11 +399,12 @@ async def _send_json(_data): return agent, participant, mock_stream async def test_silent_frames_do_not_trigger_flush(self): - """Only silence frames — no flush, no transcript event.""" + """No VAD events — no commit fires, no transcript event.""" silence = _make_audio_frame(amplitude=0) agent, participant, mock_stream = self._full_pipeline_setup( audio_frames=[silence], ws_messages=[], + vad_events=[], # Silero never fires → no commit → no transcript ) emitted = [] @@ -508,3 +539,186 @@ async def test_all_events_of_an_utterance_share_start_time(self): assert len(start_times) == 1, ( f"all events for one utterance must share start_time, got {start_times}" ) + + async def test_max_buffer_split_reopens_stream(self, monkeypatch): + """ + Regression for the is_in_speech / stream_open dual-ownership bug: a + continuous utterance that exceeds the max-buffer cap (speaker never + pauses, so Silero emits START but no END) must REOPEN a fresh streaming + request after each forced close — otherwise the rest of the utterance is + appended with no opening commit and the server silently stops streaming. + """ + import providers.voxtral_realtime as vr + + # Trip the safety cap after ~2 frames (each _make_audio_frame is 0.01 s). + monkeypatch.setattr(vr, "_MAX_BUFFER_DURATION_S", 0.015) + + agent = _make_agent( + interim_results=True, + # START only, no END → speaker stays "in speech" the whole time. + vad_events=[_make_vad_event(agents_vad.VADEventType.START_OF_SPEECH)], + ) + participant = MagicMock(spec=rtc.RemoteParticipant) + participant.identity = "user_split" + + sent: list[dict] = [] + + async def _send_json(data): + sent.append(data) + await asyncio.sleep(0) + + mock_ws = AsyncMock() + mock_ws.receive = AsyncMock( + side_effect=[_text_ws_msg({"type": "session.created"})] + ) + mock_ws.send_json = _send_json + cm = AsyncMock() + cm.__aenter__ = AsyncMock(return_value=mock_ws) + cm.__aexit__ = AsyncMock(return_value=False) + mock_session = MagicMock() + mock_session.ws_connect = MagicMock(return_value=cm) + agent._http_session = mock_session + + frames = [_make_loud_frame() for _ in range(6)] + audio_events = [MagicMock(frame=f) for f in frames] + mock_stream = AsyncMock() + mock_stream.__aiter__.return_value = iter(audio_events) + mock_stream.aclose = AsyncMock() + + with patch("providers.voxtral_realtime.rtc.AudioStream", return_value=mock_stream): + await agent._run_transcription_pipeline(participant, MagicMock(), "en") + + commits = [m for m in sent if m.get("type") == "input_audio_buffer.commit"] + closers = [m for m in commits if m.get("final") is True] + bare = [m for m in commits if "final" not in m] + # Each open sends one bare commit; each close sends one bare + one final. + openers = len(bare) - len(closers) + assert openers >= 2, ( + f"expected the stream to reopen after a max-buffer split " + f"(>=2 opener commits), got {openers}" + ) + + async def test_max_buffer_split_segments_get_distinct_start_times( + self, monkeypatch + ): + """ + Regression for the transcript-overwrite bug: when one long utterance is + split by the max-buffer cap, Silero fires no new START_OF_SPEECH, so a + shared "speech start" would give both segments the same start_time — + the same BBB transcriptId — and segment 2's text would REPLACE segment + 1's in the transcript. Each opener must record its own start time and + the reader must pair segments with them in FIFO order. + """ + import providers.voxtral_realtime as vr + + monkeypatch.setattr(vr, "_MAX_BUFFER_DURATION_S", 0.015) + # Deterministic, strictly-increasing clock so the two openers cannot + # land on the same wall-clock value (real splits are ~8 s apart). + fake_now = [1000.0] + + def _fake_time(): + fake_now[0] += 1.0 + return fake_now[0] + + monkeypatch.setattr(vr.time, "time", _fake_time) + + agent = _make_agent( + interim_results=True, + # START only, no END → continuous speech across the split. + vad_events=[_make_vad_event(agents_vad.VADEventType.START_OF_SPEECH)], + ) + participant = MagicMock(spec=rtc.RemoteParticipant) + participant.identity = "user_split_ts" + + sent: list[dict] = [] + + async def _send_json(data): + sent.append(data) + await asyncio.sleep(0) + + def _bare_commits(): + return [ + m + for m in sent + if m.get("type") == "input_audio_buffer.commit" and "final" not in m + ] + + def _closers(): + return [ + m + for m in sent + if m.get("type") == "input_audio_buffer.commit" + and m.get("final") is True + ] + + # Deliver each segment's transcription only after the writer has sent + # the corresponding commits — mirroring the real server's causality. + # Segment 1 events after its close; segment 2 events after reopen + # (open1 + close1 + open2 = 3 bare commits). + closed_msg = MagicMock() + closed_msg.type = aiohttp.WSMsgType.CLOSED + script = [ + (lambda: True, _text_ws_msg({"type": "session.created"})), + ( + lambda: len(_closers()) >= 1, + _text_ws_msg({"type": "transcription.delta", "delta": "hello"}), + ), + ( + lambda: len(_closers()) >= 1, + _text_ws_msg({"type": "transcription.done", "text": "hello"}), + ), + ( + lambda: len(_bare_commits()) >= 3, + _text_ws_msg({"type": "transcription.delta", "delta": "world"}), + ), + ( + lambda: len(_bare_commits()) >= 3, + _text_ws_msg({"type": "transcription.done", "text": "world"}), + ), + (lambda: True, closed_msg), + ] + script_iter = iter(script) + + async def _receive(): + try: + cond, msg = next(script_iter) + except StopIteration: + return closed_msg + while not cond(): + await asyncio.sleep(0) + return msg + + mock_ws = AsyncMock() + mock_ws.receive = _receive + mock_ws.send_json = _send_json + cm = AsyncMock() + cm.__aenter__ = AsyncMock(return_value=mock_ws) + cm.__aexit__ = AsyncMock(return_value=False) + mock_session = MagicMock() + mock_session.ws_connect = MagicMock(return_value=cm) + agent._http_session = mock_session + + frames = [_make_loud_frame() for _ in range(6)] + audio_events = [MagicMock(frame=f) for f in frames] + mock_stream = AsyncMock() + mock_stream.__aiter__.return_value = iter(audio_events) + mock_stream.aclose = AsyncMock() + + final = [] + agent.on("final_transcript", lambda **kw: final.append(kw)) + + with patch( + "providers.voxtral_realtime.rtc.AudioStream", return_value=mock_stream + ): + await agent._run_transcription_pipeline(participant, MagicMock(), "en") + await asyncio.sleep(0) + + texts = [kw["event"].alternatives[0].text for kw in final] + assert texts == ["hello", "world"] + + starts = [kw["event"].alternatives[0].start_time for kw in final] + assert starts[0] < starts[1], ( + f"split segments must have distinct, increasing start_times " + f"(distinct BBB transcriptIds) — got {starts}; equal values mean " + f"segment 2 overwrites segment 1 in the transcript" + ) diff --git a/uv.lock b/uv.lock index 43305a8..48599f9 100644 --- a/uv.lock +++ b/uv.lock @@ -1,10 +1,9 @@ version = 1 revision = 3 -requires-python = ">=3.10" +requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.13'", - "python_full_version >= '3.11' and python_full_version < '3.13'", - "python_full_version < '3.11'", + "python_full_version < '3.13'", ] [[package]] @@ -32,7 +31,6 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, { name = "aiosignal" }, - { name = "async-timeout", marker = "python_full_version < '3.11'" }, { name = "attrs" }, { name = "frozenlist" }, { name = "multidict" }, @@ -41,23 +39,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/9b/e7/d92a237d8802ca88483906c388f7c201bbe96cd80a165ffd0ac2f6a8d59f/aiohttp-3.12.15.tar.gz", hash = "sha256:4fc61385e9c98d72fcdf47e6dd81833f47b2f77c114c29cd64a361be57a763a2", size = 7823716, upload-time = "2025-07-29T05:52:32.215Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/47/dc/ef9394bde9080128ad401ac7ede185267ed637df03b51f05d14d1c99ad67/aiohttp-3.12.15-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b6fc902bff74d9b1879ad55f5404153e2b33a82e72a95c89cec5eb6cc9e92fbc", size = 703921, upload-time = "2025-07-29T05:49:43.584Z" }, - { url = "https://files.pythonhosted.org/packages/8f/42/63fccfc3a7ed97eb6e1a71722396f409c46b60a0552d8a56d7aad74e0df5/aiohttp-3.12.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:098e92835b8119b54c693f2f88a1dec690e20798ca5f5fe5f0520245253ee0af", size = 480288, upload-time = "2025-07-29T05:49:47.851Z" }, - { url = "https://files.pythonhosted.org/packages/9c/a2/7b8a020549f66ea2a68129db6960a762d2393248f1994499f8ba9728bbed/aiohttp-3.12.15-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:40b3fee496a47c3b4a39a731954c06f0bd9bd3e8258c059a4beb76ac23f8e421", size = 468063, upload-time = "2025-07-29T05:49:49.789Z" }, - { url = "https://files.pythonhosted.org/packages/8f/f5/d11e088da9176e2ad8220338ae0000ed5429a15f3c9dfd983f39105399cd/aiohttp-3.12.15-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ce13fcfb0bb2f259fb42106cdc63fa5515fb85b7e87177267d89a771a660b79", size = 1650122, upload-time = "2025-07-29T05:49:51.874Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6b/b60ce2757e2faed3d70ed45dafee48cee7bfb878785a9423f7e883f0639c/aiohttp-3.12.15-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3beb14f053222b391bf9cf92ae82e0171067cc9c8f52453a0f1ec7c37df12a77", size = 1624176, upload-time = "2025-07-29T05:49:53.805Z" }, - { url = "https://files.pythonhosted.org/packages/dd/de/8c9fde2072a1b72c4fadecf4f7d4be7a85b1d9a4ab333d8245694057b4c6/aiohttp-3.12.15-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c39e87afe48aa3e814cac5f535bc6199180a53e38d3f51c5e2530f5aa4ec58c", size = 1696583, upload-time = "2025-07-29T05:49:55.338Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ad/07f863ca3d895a1ad958a54006c6dafb4f9310f8c2fdb5f961b8529029d3/aiohttp-3.12.15-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5f1b4ce5bc528a6ee38dbf5f39bbf11dd127048726323b72b8e85769319ffc4", size = 1738896, upload-time = "2025-07-29T05:49:57.045Z" }, - { url = "https://files.pythonhosted.org/packages/20/43/2bd482ebe2b126533e8755a49b128ec4e58f1a3af56879a3abdb7b42c54f/aiohttp-3.12.15-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1004e67962efabbaf3f03b11b4c43b834081c9e3f9b32b16a7d97d4708a9abe6", size = 1643561, upload-time = "2025-07-29T05:49:58.762Z" }, - { url = "https://files.pythonhosted.org/packages/23/40/2fa9f514c4cf4cbae8d7911927f81a1901838baf5e09a8b2c299de1acfe5/aiohttp-3.12.15-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8faa08fcc2e411f7ab91d1541d9d597d3a90e9004180edb2072238c085eac8c2", size = 1583685, upload-time = "2025-07-29T05:50:00.375Z" }, - { url = "https://files.pythonhosted.org/packages/b8/c3/94dc7357bc421f4fb978ca72a201a6c604ee90148f1181790c129396ceeb/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fe086edf38b2222328cdf89af0dde2439ee173b8ad7cb659b4e4c6f385b2be3d", size = 1627533, upload-time = "2025-07-29T05:50:02.306Z" }, - { url = "https://files.pythonhosted.org/packages/bf/3f/1f8911fe1844a07001e26593b5c255a685318943864b27b4e0267e840f95/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:79b26fe467219add81d5e47b4a4ba0f2394e8b7c7c3198ed36609f9ba161aecb", size = 1638319, upload-time = "2025-07-29T05:50:04.282Z" }, - { url = "https://files.pythonhosted.org/packages/4e/46/27bf57a99168c4e145ffee6b63d0458b9c66e58bb70687c23ad3d2f0bd17/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b761bac1192ef24e16706d761aefcb581438b34b13a2f069a6d343ec8fb693a5", size = 1613776, upload-time = "2025-07-29T05:50:05.863Z" }, - { url = "https://files.pythonhosted.org/packages/0f/7e/1d2d9061a574584bb4ad3dbdba0da90a27fdc795bc227def3a46186a8bc1/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e153e8adacfe2af562861b72f8bc47f8a5c08e010ac94eebbe33dc21d677cd5b", size = 1693359, upload-time = "2025-07-29T05:50:07.563Z" }, - { url = "https://files.pythonhosted.org/packages/08/98/bee429b52233c4a391980a5b3b196b060872a13eadd41c3a34be9b1469ed/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:fc49c4de44977aa8601a00edbf157e9a421f227aa7eb477d9e3df48343311065", size = 1716598, upload-time = "2025-07-29T05:50:09.33Z" }, - { url = "https://files.pythonhosted.org/packages/57/39/b0314c1ea774df3392751b686104a3938c63ece2b7ce0ba1ed7c0b4a934f/aiohttp-3.12.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2776c7ec89c54a47029940177e75c8c07c29c66f73464784971d6a81904ce9d1", size = 1644940, upload-time = "2025-07-29T05:50:11.334Z" }, - { url = "https://files.pythonhosted.org/packages/1b/83/3dacb8d3f8f512c8ca43e3fa8a68b20583bd25636ffa4e56ee841ffd79ae/aiohttp-3.12.15-cp310-cp310-win32.whl", hash = "sha256:2c7d81a277fa78b2203ab626ced1487420e8c11a8e373707ab72d189fcdad20a", size = 429239, upload-time = "2025-07-29T05:50:12.803Z" }, - { url = "https://files.pythonhosted.org/packages/eb/f9/470b5daba04d558c9673ca2034f28d067f3202a40e17804425f0c331c89f/aiohttp-3.12.15-cp310-cp310-win_amd64.whl", hash = "sha256:83603f881e11f0f710f8e2327817c82e79431ec976448839f3cd05d7afe8f830", size = 452297, upload-time = "2025-07-29T05:50:14.266Z" }, { url = "https://files.pythonhosted.org/packages/20/19/9e86722ec8e835959bd97ce8c1efa78cf361fa4531fca372551abcc9cdd6/aiohttp-3.12.15-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d3ce17ce0220383a0f9ea07175eeaa6aa13ae5a41f30bc61d84df17f0e9b1117", size = 711246, upload-time = "2025-07-29T05:50:15.937Z" }, { url = "https://files.pythonhosted.org/packages/71/f9/0a31fcb1a7d4629ac9d8f01f1cb9242e2f9943f47f5d03215af91c3c1a26/aiohttp-3.12.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:010cc9bbd06db80fe234d9003f67e97a10fe003bfbedb40da7d71c1008eda0fe", size = 483515, upload-time = "2025-07-29T05:50:17.442Z" }, { url = "https://files.pythonhosted.org/packages/62/6c/94846f576f1d11df0c2e41d3001000527c0fdf63fce7e69b3927a731325d/aiohttp-3.12.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3f9d7c55b41ed687b9d7165b17672340187f87a773c98236c987f08c858145a9", size = 471776, upload-time = "2025-07-29T05:50:19.568Z" }, @@ -147,7 +128,6 @@ name = "anyio" version = "4.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, { name = "sniffio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, @@ -181,15 +161,6 @@ version = "15.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/17/89/940a509ee7e9449f0c877fa984b37b7cc485546035cc67bbc353f2ac20f3/av-15.0.0.tar.gz", hash = "sha256:871c1a9becddf00b60b1294dc0bff9ff193ac31286aeec1a34039bd27e650183", size = 3833128, upload-time = "2025-07-03T16:23:48.455Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/c6/4646aeffca77fb9e557509528341fdef409f7e5de44c70858fb639bb9a3e/av-15.0.0-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:f20c7565ad9aed8a5e3ca7ed30b151d4d8a937e072b6a4901c3200134fe7c68b", size = 21808084, upload-time = "2025-07-03T16:21:17.028Z" }, - { url = "https://files.pythonhosted.org/packages/89/a2/8a0349e2ebf998f2305b61365240a748bc137f94f431e769c2ac83c5a321/av-15.0.0-cp310-cp310-macosx_13_0_x86_64.whl", hash = "sha256:0d8b78a88f0fdaf6591bca32b41301e40ba60be294b0698318948c4d1fa6f206", size = 26989279, upload-time = "2025-07-03T16:21:20.63Z" }, - { url = "https://files.pythonhosted.org/packages/de/f7/2e3b9cc831a1891914ca09aaeac88195f36f24a22f8c18e57637604a8ef1/av-15.0.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:5d14f6cea6c4966d5478a50555fa92af4948f83e7843b63b747d4a451c53d4f1", size = 33955236, upload-time = "2025-07-03T16:21:23.752Z" }, - { url = "https://files.pythonhosted.org/packages/23/fa/cc4e32d85d6e765f9e9c2680ce9bee6a4d66c8a069f136322be04a66e70d/av-15.0.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:09f516947890dcf27482af2f0f7b31a579dbd11d5566dd74ce5f1f6396c452b7", size = 37681552, upload-time = "2025-07-03T16:21:27.265Z" }, - { url = "https://files.pythonhosted.org/packages/f9/e3/438e1095c064fd21f1325ddae9383b4bcdc8f8493247144ed15bc1b931a2/av-15.0.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:908e2fb4358210a463f81e2dbfac77d5977cc53a400fea3f6decef6f9f9267e4", size = 39179769, upload-time = "2025-07-03T16:21:31.941Z" }, - { url = "https://files.pythonhosted.org/packages/b7/b7/e6c27a8bd75e3eede07c1ce888fc1aa6293ba35393d2f4adc1d2e41d563b/av-15.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:911fe092b1c75c35d3e9d836b750ff725599a343b6126449cb2c1b4aa8ac2792", size = 39725200, upload-time = "2025-07-03T16:21:35.73Z" }, - { url = "https://files.pythonhosted.org/packages/d1/42/06e91b07c77465af1b845ac5cf83be1b4cbe042fd940509ae3c5ad70e386/av-15.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:25743a08b674596f3b993392259a4953a445b4211796d168c992174c983b76f0", size = 36639563, upload-time = "2025-07-03T16:21:39.395Z" }, - { url = "https://files.pythonhosted.org/packages/f8/d6/92aafdd8ef420100aca3503b7772ca2484d3688b83b09ca6f96bfb47b7c1/av-15.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2ecd5df62b9697a9304a084fbfed13fa890ec9ba2f647aaed35dca291991c7b1", size = 40482430, upload-time = "2025-07-03T16:21:42.671Z" }, - { url = "https://files.pythonhosted.org/packages/41/1a/22d7b2a151d4aeff6a1fb530e25c8d677dd59580418cab4a95c4628d5009/av-15.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:7156d1b326e328aaba7f10a0d89bec53d087aba16f4c5c7ae13890b9eefde972", size = 31363168, upload-time = "2025-07-03T16:21:45.809Z" }, { url = "https://files.pythonhosted.org/packages/e5/2a/40e0ec34e8235e4a1f9fe60288cd1eebe6413765931b5b74aeb3ce79c422/av-15.0.0-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:eb19386466aafbac4ede549ed7dc6198714e8d35ecc238d5b5c0d91e770d53d4", size = 21793541, upload-time = "2025-07-03T16:21:48.819Z" }, { url = "https://files.pythonhosted.org/packages/7d/21/74acec5492a901699a94715e94cb83772679b92183592a3d8b3e58cf0202/av-15.0.0-cp311-cp311-macosx_13_0_x86_64.whl", hash = "sha256:e3c841befff26823524f3260d29fb3162540535c43238587b24226d345c82af3", size = 26973175, upload-time = "2025-07-03T16:21:51.63Z" }, { url = "https://files.pythonhosted.org/packages/7b/d9/04e7fc09c6246aaf8e695620cc026779e366c49dcab561f8f434fbed3256/av-15.0.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:fe50ddab68af27bb9f7123dac5b1ff43ee8c7d941499c625018f3cac7da01ff3", size = 34423925, upload-time = "2025-07-03T16:21:54.628Z" }, @@ -219,21 +190,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c9/f0/fe14adaa670ab7a3f709805a8494fd0a2eeb6a5b18b8c59dc6014639a5b1/av-15.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:5758231163b5486dfbf664036be010b7f5ebb24564aaeb62577464be5ea996e0", size = 31332650, upload-time = "2025-07-03T16:23:16.558Z" }, ] -[[package]] -name = "backports-asyncio-runner" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, -] - [[package]] name = "bbb-livekit-stt" version = "0.2.0" source = { virtual = "." } dependencies = [ - { name = "livekit-agents", extra = ["gladia"] }, + { name = "livekit-agents", extra = ["gladia", "silero"] }, { name = "nest-asyncio" }, { name = "python-dotenv" }, { name = "redis" }, @@ -249,7 +211,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "livekit-agents", extras = ["gladia"], specifier = "~=1.4" }, + { name = "livekit-agents", extras = ["gladia", "silero"], specifier = "~=1.4" }, { name = "nest-asyncio", specifier = "~=1.6.0" }, { name = "python-dotenv", specifier = "~=1.1.1" }, { name = "redis", specifier = "~=6.4.0" }, @@ -281,18 +243,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload-time = "2024-09-04T20:45:21.852Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/90/07/f44ca684db4e4f08a3fdc6eeb9a0d15dc6883efc7b8c90357fdbf74e186c/cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14", size = 182191, upload-time = "2024-09-04T20:43:30.027Z" }, - { url = "https://files.pythonhosted.org/packages/08/fd/cc2fedbd887223f9f5d170c96e57cbf655df9831a6546c1727ae13fa977a/cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67", size = 178592, upload-time = "2024-09-04T20:43:32.108Z" }, - { url = "https://files.pythonhosted.org/packages/de/cc/4635c320081c78d6ffc2cab0a76025b691a91204f4aa317d568ff9280a2d/cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", size = 426024, upload-time = "2024-09-04T20:43:34.186Z" }, - { url = "https://files.pythonhosted.org/packages/b6/7b/3b2b250f3aab91abe5f8a51ada1b717935fdaec53f790ad4100fe2ec64d1/cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", size = 448188, upload-time = "2024-09-04T20:43:36.286Z" }, - { url = "https://files.pythonhosted.org/packages/d3/48/1b9283ebbf0ec065148d8de05d647a986c5f22586b18120020452fff8f5d/cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", size = 455571, upload-time = "2024-09-04T20:43:38.586Z" }, - { url = "https://files.pythonhosted.org/packages/40/87/3b8452525437b40f39ca7ff70276679772ee7e8b394934ff60e63b7b090c/cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", size = 436687, upload-time = "2024-09-04T20:43:40.084Z" }, - { url = "https://files.pythonhosted.org/packages/8d/fb/4da72871d177d63649ac449aec2e8a29efe0274035880c7af59101ca2232/cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", size = 446211, upload-time = "2024-09-04T20:43:41.526Z" }, - { url = "https://files.pythonhosted.org/packages/ab/a0/62f00bcb411332106c02b663b26f3545a9ef136f80d5df746c05878f8c4b/cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", size = 461325, upload-time = "2024-09-04T20:43:43.117Z" }, - { url = "https://files.pythonhosted.org/packages/36/83/76127035ed2e7e27b0787604d99da630ac3123bfb02d8e80c633f218a11d/cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", size = 438784, upload-time = "2024-09-04T20:43:45.256Z" }, - { url = "https://files.pythonhosted.org/packages/21/81/a6cd025db2f08ac88b901b745c163d884641909641f9b826e8cb87645942/cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", size = 461564, upload-time = "2024-09-04T20:43:46.779Z" }, - { url = "https://files.pythonhosted.org/packages/f8/fe/4d41c2f200c4a457933dbd98d3cf4e911870877bd94d9656cc0fcb390681/cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", size = 171804, upload-time = "2024-09-04T20:43:48.186Z" }, - { url = "https://files.pythonhosted.org/packages/d1/b6/0b0f5ab93b0df4acc49cae758c81fe4e5ef26c3ae2e10cc69249dfd8b3ab/cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", size = 181299, upload-time = "2024-09-04T20:43:49.812Z" }, { url = "https://files.pythonhosted.org/packages/6b/f4/927e3a8899e52a27fa57a48607ff7dc91a9ebe97399b357b85a0c7892e00/cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", size = 182264, upload-time = "2024-09-04T20:43:51.124Z" }, { url = "https://files.pythonhosted.org/packages/6c/f5/6c3a8efe5f503175aaddcbea6ad0d2c96dad6f5abb205750d1b3df44ef29/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", size = 178651, upload-time = "2024-09-04T20:43:52.872Z" }, { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259, upload-time = "2024-09-04T20:43:56.123Z" }, @@ -335,17 +285,6 @@ version = "3.4.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/98/f3b8013223728a99b908c9344da3aa04ee6e3fa235f19409033eda92fb78/charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72", size = 207695, upload-time = "2025-08-09T07:55:36.452Z" }, - { url = "https://files.pythonhosted.org/packages/21/40/5188be1e3118c82dcb7c2a5ba101b783822cfb413a0268ed3be0468532de/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe", size = 147153, upload-time = "2025-08-09T07:55:38.467Z" }, - { url = "https://files.pythonhosted.org/packages/37/60/5d0d74bc1e1380f0b72c327948d9c2aca14b46a9efd87604e724260f384c/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601", size = 160428, upload-time = "2025-08-09T07:55:40.072Z" }, - { url = "https://files.pythonhosted.org/packages/85/9a/d891f63722d9158688de58d050c59dc3da560ea7f04f4c53e769de5140f5/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c", size = 157627, upload-time = "2025-08-09T07:55:41.706Z" }, - { url = "https://files.pythonhosted.org/packages/65/1a/7425c952944a6521a9cfa7e675343f83fd82085b8af2b1373a2409c683dc/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2", size = 152388, upload-time = "2025-08-09T07:55:43.262Z" }, - { url = "https://files.pythonhosted.org/packages/f0/c9/a2c9c2a355a8594ce2446085e2ec97fd44d323c684ff32042e2a6b718e1d/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0", size = 150077, upload-time = "2025-08-09T07:55:44.903Z" }, - { url = "https://files.pythonhosted.org/packages/3b/38/20a1f44e4851aa1c9105d6e7110c9d020e093dfa5836d712a5f074a12bf7/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0", size = 161631, upload-time = "2025-08-09T07:55:46.346Z" }, - { url = "https://files.pythonhosted.org/packages/a4/fa/384d2c0f57edad03d7bec3ebefb462090d8905b4ff5a2d2525f3bb711fac/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0", size = 159210, upload-time = "2025-08-09T07:55:47.539Z" }, - { url = "https://files.pythonhosted.org/packages/33/9e/eca49d35867ca2db336b6ca27617deed4653b97ebf45dfc21311ce473c37/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a", size = 153739, upload-time = "2025-08-09T07:55:48.744Z" }, - { url = "https://files.pythonhosted.org/packages/2a/91/26c3036e62dfe8de8061182d33be5025e2424002125c9500faff74a6735e/charset_normalizer-3.4.3-cp310-cp310-win32.whl", hash = "sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f", size = 99825, upload-time = "2025-08-09T07:55:50.305Z" }, - { url = "https://files.pythonhosted.org/packages/e2/c6/f05db471f81af1fa01839d44ae2a8bfeec8d2a8b4590f16c4e7393afd323/charset_normalizer-3.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669", size = 107452, upload-time = "2025-08-09T07:55:51.461Z" }, { url = "https://files.pythonhosted.org/packages/7f/b5/991245018615474a60965a7c9cd2b4efbaabd16d582a5547c47ee1c7730b/charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b", size = 204483, upload-time = "2025-08-09T07:55:53.12Z" }, { url = "https://files.pythonhosted.org/packages/c7/2a/ae245c41c06299ec18262825c1569c5d3298fc920e4ddf56ab011b417efd/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64", size = 145520, upload-time = "2025-08-09T07:55:54.712Z" }, { url = "https://files.pythonhosted.org/packages/3a/a4/b3b6c76e7a635748c4421d2b92c7b8f90a432f98bda5082049af37ffc8e3/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91", size = 158876, upload-time = "2025-08-09T07:55:56.024Z" }, @@ -420,20 +359,6 @@ version = "7.13.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/44/d4/7827d9ffa34d5d4d752eec907022aa417120936282fc488306f5da08c292/coverage-7.13.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fc31c787a84f8cd6027eba44010517020e0d18487064cd3d8968941856d1415", size = 219152, upload-time = "2026-02-09T12:56:11.974Z" }, - { url = "https://files.pythonhosted.org/packages/35/b0/d69df26607c64043292644dbb9dc54b0856fabaa2cbb1eeee3331cc9e280/coverage-7.13.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a32ebc02a1805adf637fc8dec324b5cdacd2e493515424f70ee33799573d661b", size = 219667, upload-time = "2026-02-09T12:56:13.33Z" }, - { url = "https://files.pythonhosted.org/packages/82/a4/c1523f7c9e47b2271dbf8c2a097e7a1f89ef0d66f5840bb59b7e8814157b/coverage-7.13.4-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e24f9156097ff9dc286f2f913df3a7f63c0e333dcafa3c196f2c18b4175ca09a", size = 246425, upload-time = "2026-02-09T12:56:14.552Z" }, - { url = "https://files.pythonhosted.org/packages/f8/02/aa7ec01d1a5023c4b680ab7257f9bfde9defe8fdddfe40be096ac19e8177/coverage-7.13.4-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8041b6c5bfdc03257666e9881d33b1abc88daccaf73f7b6340fb7946655cd10f", size = 248229, upload-time = "2026-02-09T12:56:16.31Z" }, - { url = "https://files.pythonhosted.org/packages/35/98/85aba0aed5126d896162087ef3f0e789a225697245256fc6181b95f47207/coverage-7.13.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a09cfa6a5862bc2fc6ca7c3def5b2926194a56b8ab78ffcf617d28911123012", size = 250106, upload-time = "2026-02-09T12:56:18.024Z" }, - { url = "https://files.pythonhosted.org/packages/96/72/1db59bd67494bc162e3e4cd5fbc7edba2c7026b22f7c8ef1496d58c2b94c/coverage-7.13.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:296f8b0af861d3970c2a4d8c91d48eb4dd4771bcef9baedec6a9b515d7de3def", size = 252021, upload-time = "2026-02-09T12:56:19.272Z" }, - { url = "https://files.pythonhosted.org/packages/9d/97/72899c59c7066961de6e3daa142d459d47d104956db43e057e034f015c8a/coverage-7.13.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e101609bcbbfb04605ea1027b10dc3735c094d12d40826a60f897b98b1c30256", size = 247114, upload-time = "2026-02-09T12:56:21.051Z" }, - { url = "https://files.pythonhosted.org/packages/39/1f/f1885573b5970235e908da4389176936c8933e86cb316b9620aab1585fa2/coverage-7.13.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aa3feb8db2e87ff5e6d00d7e1480ae241876286691265657b500886c98f38bda", size = 248143, upload-time = "2026-02-09T12:56:22.585Z" }, - { url = "https://files.pythonhosted.org/packages/a8/cf/e80390c5b7480b722fa3e994f8202807799b85bc562aa4f1dde209fbb7be/coverage-7.13.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4fc7fa81bbaf5a02801b65346c8b3e657f1d93763e58c0abdf7c992addd81a92", size = 246152, upload-time = "2026-02-09T12:56:23.748Z" }, - { url = "https://files.pythonhosted.org/packages/44/bf/f89a8350d85572f95412debb0fb9bb4795b1d5b5232bd652923c759e787b/coverage-7.13.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:33901f604424145c6e9c2398684b92e176c0b12df77d52db81c20abd48c3794c", size = 249959, upload-time = "2026-02-09T12:56:25.209Z" }, - { url = "https://files.pythonhosted.org/packages/f7/6e/612a02aece8178c818df273e8d1642190c4875402ca2ba74514394b27aba/coverage-7.13.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:bb28c0f2cf2782508a40cec377935829d5fcc3ad9a3681375af4e84eb34b6b58", size = 246416, upload-time = "2026-02-09T12:56:26.475Z" }, - { url = "https://files.pythonhosted.org/packages/cb/98/b5afc39af67c2fa6786b03c3a7091fc300947387ce8914b096db8a73d67a/coverage-7.13.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d107aff57a83222ddbd8d9ee705ede2af2cc926608b57abed8ef96b50b7e8f9", size = 247025, upload-time = "2026-02-09T12:56:27.727Z" }, - { url = "https://files.pythonhosted.org/packages/51/30/2bba8ef0682d5bd210c38fe497e12a06c9f8d663f7025e9f5c2c31ce847d/coverage-7.13.4-cp310-cp310-win32.whl", hash = "sha256:a6f94a7d00eb18f1b6d403c91a88fd58cfc92d4b16080dfdb774afc8294469bf", size = 221758, upload-time = "2026-02-09T12:56:29.051Z" }, - { url = "https://files.pythonhosted.org/packages/78/13/331f94934cf6c092b8ea59ff868eb587bc8fe0893f02c55bc6c0183a192e/coverage-7.13.4-cp310-cp310-win_amd64.whl", hash = "sha256:2cb0f1e000ebc419632bbe04366a8990b6e32c4e0b51543a6484ffe15eaeda95", size = 222693, upload-time = "2026-02-09T12:56:30.366Z" }, { url = "https://files.pythonhosted.org/packages/b4/ad/b59e5b451cf7172b8d1043dc0fa718f23aab379bc1521ee13d4bd9bfa960/coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053", size = 219278, upload-time = "2026-02-09T12:56:31.673Z" }, { url = "https://files.pythonhosted.org/packages/f1/17/0cb7ca3de72e5f4ef2ec2fa0089beafbcaaaead1844e8b8a63d35173d77d/coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11", size = 219783, upload-time = "2026-02-09T12:56:33.104Z" }, { url = "https://files.pythonhosted.org/packages/ab/63/325d8e5b11e0eaf6d0f6a44fad444ae58820929a9b0de943fa377fe73e85/coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa", size = 250200, upload-time = "2026-02-09T12:56:34.474Z" }, @@ -560,15 +485,11 @@ wheels = [ ] [[package]] -name = "exceptiongroup" -version = "1.3.0" +name = "flatbuffers" +version = "25.12.19" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" }, ] [[package]] @@ -577,23 +498,6 @@ version = "1.7.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/79/b1/b64018016eeb087db503b038296fd782586432b9c077fc5c7839e9cb6ef6/frozenlist-1.7.0.tar.gz", hash = "sha256:2e310d81923c2437ea8670467121cc3e9b0f76d3043cc1d2331d56c7fb7a3a8f", size = 45078, upload-time = "2025-06-09T23:02:35.538Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/af/36/0da0a49409f6b47cc2d060dc8c9040b897b5902a8a4e37d9bc1deb11f680/frozenlist-1.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cc4df77d638aa2ed703b878dd093725b72a824c3c546c076e8fdf276f78ee84a", size = 81304, upload-time = "2025-06-09T22:59:46.226Z" }, - { url = "https://files.pythonhosted.org/packages/77/f0/77c11d13d39513b298e267b22eb6cb559c103d56f155aa9a49097221f0b6/frozenlist-1.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:716a9973a2cc963160394f701964fe25012600f3d311f60c790400b00e568b61", size = 47735, upload-time = "2025-06-09T22:59:48.133Z" }, - { url = "https://files.pythonhosted.org/packages/37/12/9d07fa18971a44150593de56b2f2947c46604819976784bcf6ea0d5db43b/frozenlist-1.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a0fd1bad056a3600047fb9462cff4c5322cebc59ebf5d0a3725e0ee78955001d", size = 46775, upload-time = "2025-06-09T22:59:49.564Z" }, - { url = "https://files.pythonhosted.org/packages/70/34/f73539227e06288fcd1f8a76853e755b2b48bca6747e99e283111c18bcd4/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3789ebc19cb811163e70fe2bd354cea097254ce6e707ae42e56f45e31e96cb8e", size = 224644, upload-time = "2025-06-09T22:59:51.35Z" }, - { url = "https://files.pythonhosted.org/packages/fb/68/c1d9c2f4a6e438e14613bad0f2973567586610cc22dcb1e1241da71de9d3/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af369aa35ee34f132fcfad5be45fbfcde0e3a5f6a1ec0712857f286b7d20cca9", size = 222125, upload-time = "2025-06-09T22:59:52.884Z" }, - { url = "https://files.pythonhosted.org/packages/b9/d0/98e8f9a515228d708344d7c6986752be3e3192d1795f748c24bcf154ad99/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac64b6478722eeb7a3313d494f8342ef3478dff539d17002f849101b212ef97c", size = 233455, upload-time = "2025-06-09T22:59:54.74Z" }, - { url = "https://files.pythonhosted.org/packages/79/df/8a11bcec5600557f40338407d3e5bea80376ed1c01a6c0910fcfdc4b8993/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f89f65d85774f1797239693cef07ad4c97fdd0639544bad9ac4b869782eb1981", size = 227339, upload-time = "2025-06-09T22:59:56.187Z" }, - { url = "https://files.pythonhosted.org/packages/50/82/41cb97d9c9a5ff94438c63cc343eb7980dac4187eb625a51bdfdb7707314/frozenlist-1.7.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1073557c941395fdfcfac13eb2456cb8aad89f9de27bae29fabca8e563b12615", size = 212969, upload-time = "2025-06-09T22:59:57.604Z" }, - { url = "https://files.pythonhosted.org/packages/13/47/f9179ee5ee4f55629e4f28c660b3fdf2775c8bfde8f9c53f2de2d93f52a9/frozenlist-1.7.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ed8d2fa095aae4bdc7fdd80351009a48d286635edffee66bf865e37a9125c50", size = 222862, upload-time = "2025-06-09T22:59:59.498Z" }, - { url = "https://files.pythonhosted.org/packages/1a/52/df81e41ec6b953902c8b7e3a83bee48b195cb0e5ec2eabae5d8330c78038/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:24c34bea555fe42d9f928ba0a740c553088500377448febecaa82cc3e88aa1fa", size = 222492, upload-time = "2025-06-09T23:00:01.026Z" }, - { url = "https://files.pythonhosted.org/packages/84/17/30d6ea87fa95a9408245a948604b82c1a4b8b3e153cea596421a2aef2754/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:69cac419ac6a6baad202c85aaf467b65ac860ac2e7f2ac1686dc40dbb52f6577", size = 238250, upload-time = "2025-06-09T23:00:03.401Z" }, - { url = "https://files.pythonhosted.org/packages/8f/00/ecbeb51669e3c3df76cf2ddd66ae3e48345ec213a55e3887d216eb4fbab3/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:960d67d0611f4c87da7e2ae2eacf7ea81a5be967861e0c63cf205215afbfac59", size = 218720, upload-time = "2025-06-09T23:00:05.282Z" }, - { url = "https://files.pythonhosted.org/packages/1a/c0/c224ce0e0eb31cc57f67742071bb470ba8246623c1823a7530be0e76164c/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:41be2964bd4b15bf575e5daee5a5ce7ed3115320fb3c2b71fca05582ffa4dc9e", size = 232585, upload-time = "2025-06-09T23:00:07.962Z" }, - { url = "https://files.pythonhosted.org/packages/55/3c/34cb694abf532f31f365106deebdeac9e45c19304d83cf7d51ebbb4ca4d1/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:46d84d49e00c9429238a7ce02dc0be8f6d7cd0cd405abd1bebdc991bf27c15bd", size = 234248, upload-time = "2025-06-09T23:00:09.428Z" }, - { url = "https://files.pythonhosted.org/packages/98/c0/2052d8b6cecda2e70bd81299e3512fa332abb6dcd2969b9c80dfcdddbf75/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:15900082e886edb37480335d9d518cec978afc69ccbc30bd18610b7c1b22a718", size = 221621, upload-time = "2025-06-09T23:00:11.32Z" }, - { url = "https://files.pythonhosted.org/packages/c5/bf/7dcebae315436903b1d98ffb791a09d674c88480c158aa171958a3ac07f0/frozenlist-1.7.0-cp310-cp310-win32.whl", hash = "sha256:400ddd24ab4e55014bba442d917203c73b2846391dd42ca5e38ff52bb18c3c5e", size = 39578, upload-time = "2025-06-09T23:00:13.526Z" }, - { url = "https://files.pythonhosted.org/packages/8f/5f/f69818f017fa9a3d24d1ae39763e29b7f60a59e46d5f91b9c6b21622f4cd/frozenlist-1.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:6eb93efb8101ef39d32d50bce242c84bcbddb4f7e9febfa7b524532a239b4464", size = 43830, upload-time = "2025-06-09T23:00:14.98Z" }, { url = "https://files.pythonhosted.org/packages/34/7e/803dde33760128acd393a27eb002f2020ddb8d99d30a44bfbaab31c5f08a/frozenlist-1.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa51e147a66b2d74de1e6e2cf5921890de6b0f4820b257465101d7f37b49fb5a", size = 82251, upload-time = "2025-06-09T23:00:16.279Z" }, { url = "https://files.pythonhosted.org/packages/75/a9/9c2c5760b6ba45eae11334db454c189d43d34a4c0b489feb2175e5e64277/frozenlist-1.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9b35db7ce1cd71d36ba24f80f0c9e7cff73a28d7a74e91fe83e23d27c7828750", size = 48183, upload-time = "2025-06-09T23:00:17.698Z" }, { url = "https://files.pythonhosted.org/packages/47/be/4038e2d869f8a2da165f35a6befb9158c259819be22eeaf9c9a8f6a87771/frozenlist-1.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34a69a85e34ff37791e94542065c8416c1afbf820b68f720452f636d5fb990cd", size = 47107, upload-time = "2025-06-09T23:00:18.952Z" }, @@ -683,16 +587,6 @@ version = "1.74.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/38/b4/35feb8f7cab7239c5b94bd2db71abb3d6adb5f335ad8f131abb6060840b6/grpcio-1.74.0.tar.gz", hash = "sha256:80d1f4fbb35b0742d3e3d3bb654b7381cd5f015f8497279a1e9c21ba623e01b1", size = 12756048, upload-time = "2025-07-24T18:54:23.039Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/66/54/68e51a90797ad7afc5b0a7881426c337f6a9168ebab73c3210b76aa7c90d/grpcio-1.74.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:85bd5cdf4ed7b2d6438871adf6afff9af7096486fcf51818a81b77ef4dd30907", size = 5481935, upload-time = "2025-07-24T18:52:43.756Z" }, - { url = "https://files.pythonhosted.org/packages/32/2a/af817c7e9843929e93e54d09c9aee2555c2e8d81b93102a9426b36e91833/grpcio-1.74.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:68c8ebcca945efff9d86d8d6d7bfb0841cf0071024417e2d7f45c5e46b5b08eb", size = 10986796, upload-time = "2025-07-24T18:52:47.219Z" }, - { url = "https://files.pythonhosted.org/packages/d5/94/d67756638d7bb07750b07d0826c68e414124574b53840ba1ff777abcd388/grpcio-1.74.0-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:e154d230dc1bbbd78ad2fdc3039fa50ad7ffcf438e4eb2fa30bce223a70c7486", size = 5983663, upload-time = "2025-07-24T18:52:49.463Z" }, - { url = "https://files.pythonhosted.org/packages/35/f5/c5e4853bf42148fea8532d49e919426585b73eafcf379a712934652a8de9/grpcio-1.74.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e8978003816c7b9eabe217f88c78bc26adc8f9304bf6a594b02e5a49b2ef9c11", size = 6653765, upload-time = "2025-07-24T18:52:51.094Z" }, - { url = "https://files.pythonhosted.org/packages/fd/75/a1991dd64b331d199935e096cc9daa3415ee5ccbe9f909aa48eded7bba34/grpcio-1.74.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3d7bd6e3929fd2ea7fbc3f562e4987229ead70c9ae5f01501a46701e08f1ad9", size = 6215172, upload-time = "2025-07-24T18:52:53.282Z" }, - { url = "https://files.pythonhosted.org/packages/01/a4/7cef3dbb3b073d0ce34fd507efc44ac4c9442a0ef9fba4fb3f5c551efef5/grpcio-1.74.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:136b53c91ac1d02c8c24201bfdeb56f8b3ac3278668cbb8e0ba49c88069e1bdc", size = 6329142, upload-time = "2025-07-24T18:52:54.927Z" }, - { url = "https://files.pythonhosted.org/packages/bf/d3/587920f882b46e835ad96014087054655312400e2f1f1446419e5179a383/grpcio-1.74.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:fe0f540750a13fd8e5da4b3eaba91a785eea8dca5ccd2bc2ffe978caa403090e", size = 7018632, upload-time = "2025-07-24T18:52:56.523Z" }, - { url = "https://files.pythonhosted.org/packages/1f/95/c70a3b15a0bc83334b507e3d2ae20ee8fa38d419b8758a4d838f5c2a7d32/grpcio-1.74.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4e4181bfc24413d1e3a37a0b7889bea68d973d4b45dd2bc68bb766c140718f82", size = 6509641, upload-time = "2025-07-24T18:52:58.495Z" }, - { url = "https://files.pythonhosted.org/packages/4b/06/2e7042d06247d668ae69ea6998eca33f475fd4e2855f94dcb2aa5daef334/grpcio-1.74.0-cp310-cp310-win32.whl", hash = "sha256:1733969040989f7acc3d94c22f55b4a9501a30f6aaacdbccfaba0a3ffb255ab7", size = 3817478, upload-time = "2025-07-24T18:53:00.128Z" }, - { url = "https://files.pythonhosted.org/packages/93/20/e02b9dcca3ee91124060b65bbf5b8e1af80b3b76a30f694b44b964ab4d71/grpcio-1.74.0-cp310-cp310-win_amd64.whl", hash = "sha256:9e912d3c993a29df6c627459af58975b2e5c897d93287939b9d5065f000249b5", size = 4493971, upload-time = "2025-07-24T18:53:02.068Z" }, { url = "https://files.pythonhosted.org/packages/e7/77/b2f06db9f240a5abeddd23a0e49eae2b6ac54d85f0e5267784ce02269c3b/grpcio-1.74.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:69e1a8180868a2576f02356565f16635b99088da7df3d45aaa7e24e73a054e31", size = 5487368, upload-time = "2025-07-24T18:53:03.548Z" }, { url = "https://files.pythonhosted.org/packages/48/99/0ac8678a819c28d9a370a663007581744a9f2a844e32f0fa95e1ddda5b9e/grpcio-1.74.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:8efe72fde5500f47aca1ef59495cb59c885afe04ac89dd11d810f2de87d935d4", size = 10999804, upload-time = "2025-07-24T18:53:05.095Z" }, { url = "https://files.pythonhosted.org/packages/45/c6/a2d586300d9e14ad72e8dc211c7aecb45fe9846a51e558c5bca0c9102c7f/grpcio-1.74.0-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:a8f0302f9ac4e9923f98d8e243939a6fb627cd048f5cd38595c97e38020dffce", size = 5987667, upload-time = "2025-07-24T18:53:07.157Z" }, @@ -798,18 +692,6 @@ version = "0.13.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/0d/5e/4ec91646aee381d01cdb9974e30882c9cd3b8c5d1079d6b5ff4af522439a/jiter-0.13.0.tar.gz", hash = "sha256:f2839f9c2c7e2dffc1bc5929a510e14ce0a946be9365fd1219e7ef342dae14f4", size = 164847, upload-time = "2026-02-02T12:37:56.441Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/5a/41da76c5ea07bec1b0472b6b2fdb1b651074d504b19374d7e130e0cdfb25/jiter-0.13.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2ffc63785fd6c7977defe49b9824ae6ce2b2e2b77ce539bdaf006c26da06342e", size = 311164, upload-time = "2026-02-02T12:35:17.688Z" }, - { url = "https://files.pythonhosted.org/packages/40/cb/4a1bf994a3e869f0d39d10e11efb471b76d0ad70ecbfb591427a46c880c2/jiter-0.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4a638816427006c1e3f0013eb66d391d7a3acda99a7b0cf091eff4497ccea33a", size = 320296, upload-time = "2026-02-02T12:35:19.828Z" }, - { url = "https://files.pythonhosted.org/packages/09/82/acd71ca9b50ecebadc3979c541cd717cce2fe2bc86236f4fa597565d8f1a/jiter-0.13.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19928b5d1ce0ff8c1ee1b9bdef3b5bfc19e8304f1b904e436caf30bc15dc6cf5", size = 352742, upload-time = "2026-02-02T12:35:21.258Z" }, - { url = "https://files.pythonhosted.org/packages/71/03/d1fc996f3aecfd42eb70922edecfb6dd26421c874503e241153ad41df94f/jiter-0.13.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:309549b778b949d731a2f0e1594a3f805716be704a73bf3ad9a807eed5eb5721", size = 363145, upload-time = "2026-02-02T12:35:24.653Z" }, - { url = "https://files.pythonhosted.org/packages/f1/61/a30492366378cc7a93088858f8991acd7d959759fe6138c12a4644e58e81/jiter-0.13.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bcdabaea26cb04e25df3103ce47f97466627999260290349a88c8136ecae0060", size = 487683, upload-time = "2026-02-02T12:35:26.162Z" }, - { url = "https://files.pythonhosted.org/packages/20/4e/4223cffa9dbbbc96ed821c5aeb6bca510848c72c02086d1ed3f1da3d58a7/jiter-0.13.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a377af27b236abbf665a69b2bdd680e3b5a0bd2af825cd3b81245279a7606c", size = 373579, upload-time = "2026-02-02T12:35:27.582Z" }, - { url = "https://files.pythonhosted.org/packages/fe/c9/b0489a01329ab07a83812d9ebcffe7820a38163c6d9e7da644f926ff877c/jiter-0.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe49d3ff6db74321f144dff9addd4a5874d3105ac5ba7c5b77fac099cfae31ae", size = 362904, upload-time = "2026-02-02T12:35:28.925Z" }, - { url = "https://files.pythonhosted.org/packages/05/af/53e561352a44afcba9a9bc67ee1d320b05a370aed8df54eafe714c4e454d/jiter-0.13.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2113c17c9a67071b0f820733c0893ed1d467b5fcf4414068169e5c2cabddb1e2", size = 392380, upload-time = "2026-02-02T12:35:30.385Z" }, - { url = "https://files.pythonhosted.org/packages/76/2a/dd805c3afb8ed5b326c5ae49e725d1b1255b9754b1b77dbecdc621b20773/jiter-0.13.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ab1185ca5c8b9491b55ebf6c1e8866b8f68258612899693e24a92c5fdb9455d5", size = 517939, upload-time = "2026-02-02T12:35:31.865Z" }, - { url = "https://files.pythonhosted.org/packages/20/2a/7b67d76f55b8fe14c937e7640389612f05f9a4145fc28ae128aaa5e62257/jiter-0.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:9621ca242547edc16400981ca3231e0c91c0c4c1ab8573a596cd9bb3575d5c2b", size = 551696, upload-time = "2026-02-02T12:35:33.306Z" }, - { url = "https://files.pythonhosted.org/packages/85/9c/57cdd64dac8f4c6ab8f994fe0eb04dc9fd1db102856a4458fcf8a99dfa62/jiter-0.13.0-cp310-cp310-win32.whl", hash = "sha256:a7637d92b1c9d7a771e8c56f445c7f84396d48f2e756e5978840ecba2fac0894", size = 204592, upload-time = "2026-02-02T12:35:34.58Z" }, - { url = "https://files.pythonhosted.org/packages/a7/38/f4f3ea5788b8a5bae7510a678cdc747eda0c45ffe534f9878ff37e7cf3b3/jiter-0.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:c1b609e5cbd2f52bb74fb721515745b407df26d7b800458bd97cb3b972c29e7d", size = 206016, upload-time = "2026-02-02T12:35:36.435Z" }, { url = "https://files.pythonhosted.org/packages/71/29/499f8c9eaa8a16751b1c0e45e6f5f1761d180da873d417996cc7bddc8eef/jiter-0.13.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ea026e70a9a28ebbdddcbcf0f1323128a8db66898a06eaad3a4e62d2f554d096", size = 311157, upload-time = "2026-02-02T12:35:37.758Z" }, { url = "https://files.pythonhosted.org/packages/50/f6/566364c777d2ab450b92100bea11333c64c38d32caf8dc378b48e5b20c46/jiter-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66aa3e663840152d18cc8ff1e4faad3dd181373491b9cfdc6004b92198d67911", size = 319729, upload-time = "2026-02-02T12:35:39.246Z" }, { url = "https://files.pythonhosted.org/packages/73/dd/560f13ec5e4f116d8ad2658781646cca91b617ae3b8758d4a5076b278f70/jiter-0.13.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3524798e70655ff19aec58c7d05adb1f074fecff62da857ea9be2b908b6d701", size = 354766, upload-time = "2026-02-02T12:35:40.662Z" }, @@ -895,8 +777,7 @@ version = "1.1.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiofiles" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy" }, { name = "protobuf" }, { name = "types-protobuf" }, ] @@ -927,8 +808,7 @@ dependencies = [ { name = "livekit-blingfire" }, { name = "livekit-protocol" }, { name = "nest-asyncio" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy" }, { name = "openai" }, { name = "opentelemetry-api" }, { name = "opentelemetry-exporter-otlp" }, @@ -951,12 +831,14 @@ wheels = [ [package.optional-dependencies] codecs = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy" }, ] gladia = [ { name = "livekit-plugins-gladia" }, ] +silero = [ + { name = "livekit-plugins-silero" }, +] [[package]] name = "livekit-api" @@ -979,11 +861,6 @@ name = "livekit-blingfire" version = "1.1.0" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/0e/e1d79fb428ad43396da2ee4217ae043e42d75b4270e97e76d20c9d17438d/livekit_blingfire-1.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fb8f6a9e69b0e58abd913e0b3b5f27bd79ae498887a9e6708c2255a6841a3f1b", size = 152217, upload-time = "2025-12-16T00:47:59.429Z" }, - { url = "https://files.pythonhosted.org/packages/d3/e6/d881bc1bf61f4bd71df7b52e89a523b4046913977794dac2d2f0453151c2/livekit_blingfire-1.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:610a7ef7b1c81be587c41241cbdac474f8461345ee066330c69f7c460f81e7e0", size = 147320, upload-time = "2025-12-16T00:48:00.553Z" }, - { url = "https://files.pythonhosted.org/packages/db/81/714a5a4cc742856cf2077ac3851d943c2a4accb4ec76d291c9d8f96fe9d5/livekit_blingfire-1.1.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bf808159597d402415ae06cbb87e8cc8c2a58d2448e0fcd0ae3cf14b114f395", size = 165503, upload-time = "2025-12-16T00:48:01.818Z" }, - { url = "https://files.pythonhosted.org/packages/35/c9/fb8ca3881dcbea2d04cc8995e501a67a450fc93cda3ec4638608030b22f1/livekit_blingfire-1.1.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9fd97f49831c34065f8db3b1407e95c6c3353f0c35b6fff78547582d3d5278", size = 173081, upload-time = "2025-12-16T00:48:03.522Z" }, - { url = "https://files.pythonhosted.org/packages/40/2b/98ba07aae81eb87d426d2bf57426a0861f3f39c41c4d15158612c1d41fc5/livekit_blingfire-1.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:e747443f3b21999ec1d6d96c2f128dc8375937795dc7bedd8fa7b2a7e54d341c", size = 129305, upload-time = "2025-12-16T00:48:04.74Z" }, { url = "https://files.pythonhosted.org/packages/fc/09/1095ace608a41810d5c0f343eff36154505487c415acd9c653a882ff2cf1/livekit_blingfire-1.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0358058ba6cba59379d22a01acef6ff8a729b0facf880c0f75d13c26f1315c9d", size = 153650, upload-time = "2025-12-16T00:48:05.976Z" }, { url = "https://files.pythonhosted.org/packages/80/a5/f4eb0e5d97334581440d37ced2a1db4fdfc8454c641c7c144e858012f1ce/livekit_blingfire-1.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a0741a8abcfaa1f3af2313271f15ac0f79777681a8e3ab9a782a68d8eb121c89", size = 148628, upload-time = "2025-12-16T00:48:06.998Z" }, { url = "https://files.pythonhosted.org/packages/89/f9/dc5ad008cb8b9c2a300bb7f7d44f022cd4970a32707eb90358290a07f0e1/livekit_blingfire-1.1.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d99d7a34c9350da3a6ea738bc282a5f5b4ac4ffb7f8aa5251dfa96070ad845f6", size = 166832, upload-time = "2025-12-16T00:48:07.919Z" }, @@ -1018,14 +895,27 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, { name = "livekit-agents", extra = ["codecs"] }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy" }, ] sdist = { url = "https://files.pythonhosted.org/packages/90/da/390bbfedb537ef379ed08c255077a7e7a47fa7b08683b3a27b721fe92b96/livekit_plugins_gladia-1.4.2.tar.gz", hash = "sha256:ddda267fd8b9668a7789ee980036d858213da9f05a7d340dd132f71d524b122a", size = 14607, upload-time = "2026-02-17T01:27:05.505Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/fd/81/83f1a11e3b1a063559b871b8eeedca139c156cf0eba6e0d91a4083eaf8e5/livekit_plugins_gladia-1.4.2-py3-none-any.whl", hash = "sha256:8c6564bac7eaef75a834d635493c82ca76db7e736b2bd0091700ed2254ef5835", size = 15281, upload-time = "2026-02-17T01:27:04.668Z" }, ] +[[package]] +name = "livekit-plugins-silero" +version = "1.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "livekit-agents" }, + { name = "numpy" }, + { name = "onnxruntime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/b2/22ee934f798ecbf4e13cdd406a5ab204634f143794aae2e2d9962e9b49fd/livekit_plugins_silero-1.4.2.tar.gz", hash = "sha256:6bbaf05b046e09ce9a64b2cfdecb7799fdb02d83edc0b9e5f4a07a4b19fb0fe0", size = 1955575, upload-time = "2026-02-17T01:27:38.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/7c/294b1123e499fbbfc2f8b49a5fdcdc9691069aab0cdf4c707e29458927ef/livekit_plugins_silero-1.4.2-py3-none-any.whl", hash = "sha256:a10e420870fadc6a4052446e73d5b65a92447937eeef6a9c0912e77e7c844ec6", size = 3903689, upload-time = "2026-02-17T01:27:37.427Z" }, +] + [[package]] name = "livekit-protocol" version = "1.1.2" @@ -1064,29 +954,8 @@ wheels = [ name = "multidict" version = "6.6.4" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] sdist = { url = "https://files.pythonhosted.org/packages/69/7f/0652e6ed47ab288e3756ea9c0df8b14950781184d4bd7883f4d87dd41245/multidict-6.6.4.tar.gz", hash = "sha256:d2d4e4787672911b48350df02ed3fa3fffdc2f2e8ca06dd6afdf34189b76a9dd", size = 101843, upload-time = "2025-08-11T12:08:48.217Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/6b/86f353088c1358e76fd30b0146947fddecee812703b604ee901e85cd2a80/multidict-6.6.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b8aa6f0bd8125ddd04a6593437bad6a7e70f300ff4180a531654aa2ab3f6d58f", size = 77054, upload-time = "2025-08-11T12:06:02.99Z" }, - { url = "https://files.pythonhosted.org/packages/19/5d/c01dc3d3788bb877bd7f5753ea6eb23c1beeca8044902a8f5bfb54430f63/multidict-6.6.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b9e5853bbd7264baca42ffc53391b490d65fe62849bf2c690fa3f6273dbcd0cb", size = 44914, upload-time = "2025-08-11T12:06:05.264Z" }, - { url = "https://files.pythonhosted.org/packages/46/44/964dae19ea42f7d3e166474d8205f14bb811020e28bc423d46123ddda763/multidict-6.6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0af5f9dee472371e36d6ae38bde009bd8ce65ac7335f55dcc240379d7bed1495", size = 44601, upload-time = "2025-08-11T12:06:06.627Z" }, - { url = "https://files.pythonhosted.org/packages/31/20/0616348a1dfb36cb2ab33fc9521de1f27235a397bf3f59338e583afadd17/multidict-6.6.4-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:d24f351e4d759f5054b641c81e8291e5d122af0fca5c72454ff77f7cbe492de8", size = 224821, upload-time = "2025-08-11T12:06:08.06Z" }, - { url = "https://files.pythonhosted.org/packages/14/26/5d8923c69c110ff51861af05bd27ca6783011b96725d59ccae6d9daeb627/multidict-6.6.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db6a3810eec08280a172a6cd541ff4a5f6a97b161d93ec94e6c4018917deb6b7", size = 242608, upload-time = "2025-08-11T12:06:09.697Z" }, - { url = "https://files.pythonhosted.org/packages/5c/cc/e2ad3ba9459aa34fa65cf1f82a5c4a820a2ce615aacfb5143b8817f76504/multidict-6.6.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a1b20a9d56b2d81e2ff52ecc0670d583eaabaa55f402e8d16dd062373dbbe796", size = 222324, upload-time = "2025-08-11T12:06:10.905Z" }, - { url = "https://files.pythonhosted.org/packages/19/db/4ed0f65701afbc2cb0c140d2d02928bb0fe38dd044af76e58ad7c54fd21f/multidict-6.6.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8c9854df0eaa610a23494c32a6f44a3a550fb398b6b51a56e8c6b9b3689578db", size = 253234, upload-time = "2025-08-11T12:06:12.658Z" }, - { url = "https://files.pythonhosted.org/packages/94/c1/5160c9813269e39ae14b73debb907bfaaa1beee1762da8c4fb95df4764ed/multidict-6.6.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4bb7627fd7a968f41905a4d6343b0d63244a0623f006e9ed989fa2b78f4438a0", size = 251613, upload-time = "2025-08-11T12:06:13.97Z" }, - { url = "https://files.pythonhosted.org/packages/05/a9/48d1bd111fc2f8fb98b2ed7f9a115c55a9355358432a19f53c0b74d8425d/multidict-6.6.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caebafea30ed049c57c673d0b36238b1748683be2593965614d7b0e99125c877", size = 241649, upload-time = "2025-08-11T12:06:15.204Z" }, - { url = "https://files.pythonhosted.org/packages/85/2a/f7d743df0019408768af8a70d2037546a2be7b81fbb65f040d76caafd4c5/multidict-6.6.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ad887a8250eb47d3ab083d2f98db7f48098d13d42eb7a3b67d8a5c795f224ace", size = 239238, upload-time = "2025-08-11T12:06:16.467Z" }, - { url = "https://files.pythonhosted.org/packages/cb/b8/4f4bb13323c2d647323f7919201493cf48ebe7ded971717bfb0f1a79b6bf/multidict-6.6.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:ed8358ae7d94ffb7c397cecb62cbac9578a83ecefc1eba27b9090ee910e2efb6", size = 233517, upload-time = "2025-08-11T12:06:18.107Z" }, - { url = "https://files.pythonhosted.org/packages/33/29/4293c26029ebfbba4f574febd2ed01b6f619cfa0d2e344217d53eef34192/multidict-6.6.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ecab51ad2462197a4c000b6d5701fc8585b80eecb90583635d7e327b7b6923eb", size = 243122, upload-time = "2025-08-11T12:06:19.361Z" }, - { url = "https://files.pythonhosted.org/packages/20/60/a1c53628168aa22447bfde3a8730096ac28086704a0d8c590f3b63388d0c/multidict-6.6.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:c5c97aa666cf70e667dfa5af945424ba1329af5dd988a437efeb3a09430389fb", size = 248992, upload-time = "2025-08-11T12:06:20.661Z" }, - { url = "https://files.pythonhosted.org/packages/a3/3b/55443a0c372f33cae5d9ec37a6a973802884fa0ab3586659b197cf8cc5e9/multidict-6.6.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:9a950b7cf54099c1209f455ac5970b1ea81410f2af60ed9eb3c3f14f0bfcf987", size = 243708, upload-time = "2025-08-11T12:06:21.891Z" }, - { url = "https://files.pythonhosted.org/packages/7c/60/a18c6900086769312560b2626b18e8cca22d9e85b1186ba77f4755b11266/multidict-6.6.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:163c7ea522ea9365a8a57832dea7618e6cbdc3cd75f8c627663587459a4e328f", size = 237498, upload-time = "2025-08-11T12:06:23.206Z" }, - { url = "https://files.pythonhosted.org/packages/11/3d/8bdd8bcaff2951ce2affccca107a404925a2beafedd5aef0b5e4a71120a6/multidict-6.6.4-cp310-cp310-win32.whl", hash = "sha256:17d2cbbfa6ff20821396b25890f155f40c986f9cfbce5667759696d83504954f", size = 41415, upload-time = "2025-08-11T12:06:24.77Z" }, - { url = "https://files.pythonhosted.org/packages/c0/53/cab1ad80356a4cd1b685a254b680167059b433b573e53872fab245e9fc95/multidict-6.6.4-cp310-cp310-win_amd64.whl", hash = "sha256:ce9a40fbe52e57e7edf20113a4eaddfacac0561a0879734e636aa6d4bb5e3fb0", size = 46046, upload-time = "2025-08-11T12:06:25.893Z" }, - { url = "https://files.pythonhosted.org/packages/cf/9a/874212b6f5c1c2d870d0a7adc5bb4cfe9b0624fa15cdf5cf757c0f5087ae/multidict-6.6.4-cp310-cp310-win_arm64.whl", hash = "sha256:01d0959807a451fe9fdd4da3e139cb5b77f7328baf2140feeaf233e1d777b729", size = 43147, upload-time = "2025-08-11T12:06:27.534Z" }, { url = "https://files.pythonhosted.org/packages/6b/7f/90a7f01e2d005d6653c689039977f6856718c75c5579445effb7e60923d1/multidict-6.6.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c7a0e9b561e6460484318a7612e725df1145d46b0ef57c6b9866441bf6e27e0c", size = 76472, upload-time = "2025-08-11T12:06:29.006Z" }, { url = "https://files.pythonhosted.org/packages/54/a3/bed07bc9e2bb302ce752f1dabc69e884cd6a676da44fb0e501b246031fdd/multidict-6.6.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6bf2f10f70acc7a2446965ffbc726e5fc0b272c97a90b485857e5c70022213eb", size = 44634, upload-time = "2025-08-11T12:06:30.374Z" }, { url = "https://files.pythonhosted.org/packages/a7/4b/ceeb4f8f33cf81277da464307afeaf164fb0297947642585884f5cad4f28/multidict-6.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66247d72ed62d5dd29752ffc1d3b88f135c6a8de8b5f63b7c14e973ef5bda19e", size = 44282, upload-time = "2025-08-11T12:06:31.958Z" }, @@ -1171,79 +1040,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, ] -[[package]] -name = "numpy" -version = "2.2.6" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, - { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, - { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, - { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, - { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, - { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, - { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, - { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, - { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, - { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, - { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, - { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, - { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, - { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, - { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, - { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, - { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, - { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, - { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, - { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, - { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, - { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, - { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, - { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, - { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, - { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, - { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, - { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, - { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, - { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, - { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" }, - { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" }, - { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" }, - { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" }, - { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, - { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, - { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, - { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, - { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" }, - { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" }, - { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" }, - { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" }, - { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" }, - { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" }, - { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, - { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, - { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, - { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, - { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, - { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, - { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, - { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, - { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, - { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, -] - [[package]] name = "numpy" version = "2.3.2" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.13'", - "python_full_version >= '3.11' and python_full_version < '3.13'", -] sdist = { url = "https://files.pythonhosted.org/packages/37/7d/3fec4199c5ffb892bed55cff901e4f39a58c81df9c44c280499e92cad264/numpy-2.3.2.tar.gz", hash = "sha256:e0486a11ec30cdecb53f184d496d1c6a20786c81e55e41640270130056f8ee48", size = 20489306, upload-time = "2025-07-24T21:32:07.553Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/96/26/1320083986108998bd487e2931eed2aeedf914b6e8905431487543ec911d/numpy-2.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:852ae5bed3478b92f093e30f785c98e0cb62fa0a939ed057c31716e18a7a22b9", size = 21259016, upload-time = "2025-07-24T20:24:35.214Z" }, @@ -1321,6 +1121,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/e3/6690b3f85a05506733c7e90b577e4762517404ea78bab2ca3a5cb1aeb78d/numpy-2.3.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6936aff90dda378c09bea075af0d9c675fe3a977a9d2402f95a87f440f59f619", size = 12977811, upload-time = "2025-07-24T21:29:18.234Z" }, ] +[[package]] +name = "onnxruntime" +version = "1.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flatbuffers" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "protobuf" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/81/29a9eb470994a75eb7b3ccf32be314d7c66675a00ac7b50294816cc2db27/onnxruntime-1.26.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ee1109ef4ef27cad90e823399e61e03b3c6c7bfe0fb820b4baf3678c15be8b3c", size = 18005108, upload-time = "2026-05-08T19:08:11.728Z" }, + { url = "https://files.pythonhosted.org/packages/66/c7/73efa6c8a4000c38fcc14947d84f234a17e5d66f203b37b7f1ad4a7b46eb/onnxruntime-1.26.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35c7c7b0ac2e02001d28fab6c9fc24e9abc5e6faa35e6e19c63cecf1406ba89f", size = 16043752, upload-time = "2026-05-08T19:07:10.707Z" }, + { url = "https://files.pythonhosted.org/packages/b6/3f/8de630f595daf6ce884d4dd95afd2a60e70ec6572e52bfee3aa2229befab/onnxruntime-1.26.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11a8df4dcfe9ad5ff0bd71a7571dbed019fabc7594676c89fe8b86ea029c246f", size = 18176043, upload-time = "2026-05-08T19:07:33.735Z" }, + { url = "https://files.pythonhosted.org/packages/9c/21/9f041de20787cd85498bd48e0ec4d098bf2a6c486e25b24b8dae1bf492b2/onnxruntime-1.26.0-cp311-cp311-win_amd64.whl", hash = "sha256:e6456718125fd777c673f3b78d4a9ab58d6adea641e9afae85ee6444f0e0e9a9", size = 13023165, upload-time = "2026-05-08T19:08:00.633Z" }, + { url = "https://files.pythonhosted.org/packages/0e/82/3b9fe0ead2557cc3adf74c74c141bd1c7c4c6a9548c610af37df199f4512/onnxruntime-1.26.0-cp311-cp311-win_arm64.whl", hash = "sha256:cd920e45b730e4a87833e2910d8ca375aaca9da6ccc09e24bce463b3356d637f", size = 12789514, upload-time = "2026-05-08T19:07:49.433Z" }, + { url = "https://files.pythonhosted.org/packages/81/b1/d111b1df656761f980d9e298a60039a9cb66036b1d039e777537743d0ac3/onnxruntime-1.26.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:05b028781b322ad74b57ce5b50aa5280bb1fe96ceec334628ade681e0b24c1ac", size = 18016624, upload-time = "2026-05-12T00:41:01.735Z" }, + { url = "https://files.pythonhosted.org/packages/f6/a0/3f9d896a0385a36bd04345d6d0b802821a5782adde562e7e135f6bb71c73/onnxruntime-1.26.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:91f2bb870a4b9224eba0a6728c1fa7a9e552b8e59e1083c51fbbc3d013f2b5c0", size = 16052692, upload-time = "2026-05-08T19:07:13.829Z" }, + { url = "https://files.pythonhosted.org/packages/7c/43/2a4e04f8dbeffad19bbcced4bcd4289bf478921518437404d6b92bdf213b/onnxruntime-1.26.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b6dd70599005bd1bf29779f04a91978b92b5e719c11a20068a8f8e535f725b6", size = 18185439, upload-time = "2026-05-08T19:07:36.299Z" }, + { url = "https://files.pythonhosted.org/packages/44/fc/026d0a7162b9c2153dac292baea9e027c42304dc1d9dc6f8ff5b4cfbaedd/onnxruntime-1.26.0-cp312-cp312-win_amd64.whl", hash = "sha256:a26374dc7fbcaae593601086b242120e13f2310558df0991da6dd8b8fac00414", size = 13026427, upload-time = "2026-05-08T19:08:03.503Z" }, + { url = "https://files.pythonhosted.org/packages/3e/27/1dcf88e45e4c69db5f7b106f2dacc3801ba98994e082ca03e1dfdf7bfe57/onnxruntime-1.26.0-cp312-cp312-win_arm64.whl", hash = "sha256:54a8053410fd31fd66469bd754fcfe8a4df9f7eb44756b4b5479bf50c842d948", size = 12796647, upload-time = "2026-05-08T19:07:52.108Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a2/c801242685e0ce48a4ca51dfafbb588765e0446397e123be53ba5598f3f5/onnxruntime-1.26.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:ccce19c5f771b8268902f77d9fed9e88f9499465d6780808faa6611a789d33f0", size = 18016563, upload-time = "2026-05-08T19:07:28.081Z" }, + { url = "https://files.pythonhosted.org/packages/e2/64/0492c0b1db04e29b2630c87cfa36f9d6872b1ca8614b90c5cad58fac7d76/onnxruntime-1.26.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdbed8cf3b672b66acb032f33a253bc27f42bce6ece48ae3fab4fa483a5e96e0", size = 16052634, upload-time = "2026-05-08T19:07:16.885Z" }, + { url = "https://files.pythonhosted.org/packages/3d/26/4d09ddc755a84fc8d5e192991626b0e0680e8f6c5d58f4f1d05c42bc48cf/onnxruntime-1.26.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c07af6fc6d5557835f2b6ee7a96d8b3235d0c57a8e230efdedaee106a8a3cbc6", size = 18185632, upload-time = "2026-05-08T19:07:38.756Z" }, + { url = "https://files.pythonhosted.org/packages/77/89/3e52249aa08fa301e217ecba07b5246a8338fa2b401e109326e3fc5be0f9/onnxruntime-1.26.0-cp313-cp313-win_amd64.whl", hash = "sha256:61bec80655efa460591c2bc655392d57d2650ce85533a6b9b3b7a790d7ea7916", size = 13026751, upload-time = "2026-05-08T19:08:06.2Z" }, + { url = "https://files.pythonhosted.org/packages/06/b3/c1c8782b14af6797c303de132d6eef26a9fb80dfacd3750ce57911d11c6b/onnxruntime-1.26.0-cp313-cp313-win_arm64.whl", hash = "sha256:a6677545ff451e3539a02746d2f207d8c5baa4a0a818886bb9d6a6eb9511ee89", size = 12796807, upload-time = "2026-05-08T19:07:54.879Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f5/47b0676408abec652c14b84d7173e389837832d850c24f87184277313e8d/onnxruntime-1.26.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e016edc15d3c19f36807e1c6b10be5b27807688c32720f91b5ae480a95215d0", size = 16057265, upload-time = "2026-05-08T19:07:19.603Z" }, + { url = "https://files.pythonhosted.org/packages/3b/45/33ab6deeef010ca844c877dd618cebc079590bbe52d2a3678e7223b1b908/onnxruntime-1.26.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f5fc48a91a046a6a5c9b147f83fb41d65d24d24923373b222cdd248f0f4f4aac", size = 18197590, upload-time = "2026-05-08T19:07:41.422Z" }, + { url = "https://files.pythonhosted.org/packages/40/89/17546c1c20f6bfc3ae41c22152378a26edfea918af3129e2139dcd7c99f3/onnxruntime-1.26.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:33a791f31432a3af1a96db5e54818b37aba5e5eefc2e6af5794c10a9118a9993", size = 18019724, upload-time = "2026-05-08T19:07:30.723Z" }, + { url = "https://files.pythonhosted.org/packages/bb/24/89457a35f6af29538a76647f2c18c3a28277e6c19234c847e7b4b7c19860/onnxruntime-1.26.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e90c00732c4553618103149d93f688e8c3063017938f8983e21a71d9f3b6d22e", size = 16054821, upload-time = "2026-05-08T19:07:22.348Z" }, + { url = "https://files.pythonhosted.org/packages/12/f9/15b2e1815cf570d238e0135529f80d2dce64e8e8818a1489cae83823c5c6/onnxruntime-1.26.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01498e80ba8988428d08c2d51b1338f89e3de2a93e6ffe555f79c68f26a5c06b", size = 18185815, upload-time = "2026-05-08T19:07:44.179Z" }, + { url = "https://files.pythonhosted.org/packages/d7/65/2e11055faf015e4b07f45b513fa49b391baf2e19d92d77d73ebee13c1004/onnxruntime-1.26.0-cp314-cp314-win_amd64.whl", hash = "sha256:7ead61450d8405167c87dd3a31d8da1d576b490a57dab1aa8b82a7da6825f5aa", size = 13349887, upload-time = "2026-05-08T19:08:08.671Z" }, + { url = "https://files.pythonhosted.org/packages/19/e4/0f9d1a5718b1781c610c1e354765a3820597081754277a6a9a2b50705702/onnxruntime-1.26.0-cp314-cp314-win_arm64.whl", hash = "sha256:31d71a53490e46910877d0902b5ad99c69a5955e5c7ea6c82863519410e1ba7c", size = 13140121, upload-time = "2026-05-08T19:07:57.804Z" }, + { url = "https://files.pythonhosted.org/packages/1c/42/3b8e635f067d06d9f45bede470b8d539d101a4166c272213158dfd08b6ce/onnxruntime-1.26.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d7b6d258fb78fdfcf049795bcfaa74dcb90ae7baa277afd21e6fd28b83f2c496", size = 16057240, upload-time = "2026-05-08T19:07:25.163Z" }, + { url = "https://files.pythonhosted.org/packages/93/99/f2be40a31b908d96b861ae0ce98582fa376c18a7f816b9d5eb4cd6aa0a4c/onnxruntime-1.26.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4eefd386a45202aefb7a5132b94f32df9d506c9edcc7faf2fc60d65183f4b183", size = 18197382, upload-time = "2026-05-08T19:07:46.965Z" }, +] + [[package]] name = "openai" version = "2.21.0" @@ -1486,22 +1323,6 @@ version = "0.3.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a6/16/43264e4a779dd8588c21a70f0709665ee8f611211bdd2c87d952cfa7c776/propcache-0.3.2.tar.gz", hash = "sha256:20d7d62e4e7ef05f221e0db2856b979540686342e7dd9973b815599c7057e168", size = 44139, upload-time = "2025-06-09T22:56:06.081Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/14/510deed325e262afeb8b360043c5d7c960da7d3ecd6d6f9496c9c56dc7f4/propcache-0.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:22d9962a358aedbb7a2e36187ff273adeaab9743373a272976d2e348d08c7770", size = 73178, upload-time = "2025-06-09T22:53:40.126Z" }, - { url = "https://files.pythonhosted.org/packages/cd/4e/ad52a7925ff01c1325653a730c7ec3175a23f948f08626a534133427dcff/propcache-0.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0d0fda578d1dc3f77b6b5a5dce3b9ad69a8250a891760a548df850a5e8da87f3", size = 43133, upload-time = "2025-06-09T22:53:41.965Z" }, - { url = "https://files.pythonhosted.org/packages/63/7c/e9399ba5da7780871db4eac178e9c2e204c23dd3e7d32df202092a1ed400/propcache-0.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3def3da3ac3ce41562d85db655d18ebac740cb3fa4367f11a52b3da9d03a5cc3", size = 43039, upload-time = "2025-06-09T22:53:43.268Z" }, - { url = "https://files.pythonhosted.org/packages/22/e1/58da211eb8fdc6fc854002387d38f415a6ca5f5c67c1315b204a5d3e9d7a/propcache-0.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9bec58347a5a6cebf239daba9bda37dffec5b8d2ce004d9fe4edef3d2815137e", size = 201903, upload-time = "2025-06-09T22:53:44.872Z" }, - { url = "https://files.pythonhosted.org/packages/c4/0a/550ea0f52aac455cb90111c8bab995208443e46d925e51e2f6ebdf869525/propcache-0.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55ffda449a507e9fbd4aca1a7d9aa6753b07d6166140e5a18d2ac9bc49eac220", size = 213362, upload-time = "2025-06-09T22:53:46.707Z" }, - { url = "https://files.pythonhosted.org/packages/5a/af/9893b7d878deda9bb69fcf54600b247fba7317761b7db11fede6e0f28bd0/propcache-0.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64a67fb39229a8a8491dd42f864e5e263155e729c2e7ff723d6e25f596b1e8cb", size = 210525, upload-time = "2025-06-09T22:53:48.547Z" }, - { url = "https://files.pythonhosted.org/packages/7c/bb/38fd08b278ca85cde36d848091ad2b45954bc5f15cce494bb300b9285831/propcache-0.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9da1cf97b92b51253d5b68cf5a2b9e0dafca095e36b7f2da335e27dc6172a614", size = 198283, upload-time = "2025-06-09T22:53:50.067Z" }, - { url = "https://files.pythonhosted.org/packages/78/8c/9fe55bd01d362bafb413dfe508c48753111a1e269737fa143ba85693592c/propcache-0.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5f559e127134b07425134b4065be45b166183fdcb433cb6c24c8e4149056ad50", size = 191872, upload-time = "2025-06-09T22:53:51.438Z" }, - { url = "https://files.pythonhosted.org/packages/54/14/4701c33852937a22584e08abb531d654c8bcf7948a8f87ad0a4822394147/propcache-0.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aff2e4e06435d61f11a428360a932138d0ec288b0a31dd9bd78d200bd4a2b339", size = 199452, upload-time = "2025-06-09T22:53:53.229Z" }, - { url = "https://files.pythonhosted.org/packages/16/44/447f2253d859602095356007657ee535e0093215ea0b3d1d6a41d16e5201/propcache-0.3.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:4927842833830942a5d0a56e6f4839bc484785b8e1ce8d287359794818633ba0", size = 191567, upload-time = "2025-06-09T22:53:54.541Z" }, - { url = "https://files.pythonhosted.org/packages/f2/b3/e4756258749bb2d3b46defcff606a2f47410bab82be5824a67e84015b267/propcache-0.3.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6107ddd08b02654a30fb8ad7a132021759d750a82578b94cd55ee2772b6ebea2", size = 193015, upload-time = "2025-06-09T22:53:56.44Z" }, - { url = "https://files.pythonhosted.org/packages/1e/df/e6d3c7574233164b6330b9fd697beeac402afd367280e6dc377bb99b43d9/propcache-0.3.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:70bd8b9cd6b519e12859c99f3fc9a93f375ebd22a50296c3a295028bea73b9e7", size = 204660, upload-time = "2025-06-09T22:53:57.839Z" }, - { url = "https://files.pythonhosted.org/packages/b2/53/e4d31dd5170b4a0e2e6b730f2385a96410633b4833dc25fe5dffd1f73294/propcache-0.3.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2183111651d710d3097338dd1893fcf09c9f54e27ff1a8795495a16a469cc90b", size = 206105, upload-time = "2025-06-09T22:53:59.638Z" }, - { url = "https://files.pythonhosted.org/packages/7f/fe/74d54cf9fbe2a20ff786e5f7afcfde446588f0cf15fb2daacfbc267b866c/propcache-0.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fb075ad271405dcad8e2a7ffc9a750a3bf70e533bd86e89f0603e607b93aa64c", size = 196980, upload-time = "2025-06-09T22:54:01.071Z" }, - { url = "https://files.pythonhosted.org/packages/22/ec/c469c9d59dada8a7679625e0440b544fe72e99311a4679c279562051f6fc/propcache-0.3.2-cp310-cp310-win32.whl", hash = "sha256:404d70768080d3d3bdb41d0771037da19d8340d50b08e104ca0e7f9ce55fce70", size = 37679, upload-time = "2025-06-09T22:54:03.003Z" }, - { url = "https://files.pythonhosted.org/packages/38/35/07a471371ac89d418f8d0b699c75ea6dca2041fbda360823de21f6a9ce0a/propcache-0.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:7435d766f978b4ede777002e6b3b6641dd229cd1da8d3d3106a45770365f9ad9", size = 41459, upload-time = "2025-06-09T22:54:04.134Z" }, { url = "https://files.pythonhosted.org/packages/80/8d/e8b436717ab9c2cfc23b116d2c297305aa4cd8339172a456d61ebf5669b8/propcache-0.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0b8d2f607bd8f80ddc04088bc2a037fdd17884a6fcadc47a96e334d72f3717be", size = 74207, upload-time = "2025-06-09T22:54:05.399Z" }, { url = "https://files.pythonhosted.org/packages/d6/29/1e34000e9766d112171764b9fa3226fa0153ab565d0c242c70e9945318a7/propcache-0.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06766d8f34733416e2e34f46fea488ad5d60726bb9481d3cddf89a6fa2d9603f", size = 43648, upload-time = "2025-06-09T22:54:08.023Z" }, { url = "https://files.pythonhosted.org/packages/46/92/1ad5af0df781e76988897da39b5f086c2bf0f028b7f9bd1f409bb05b6874/propcache-0.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2dc1f4a1df4fecf4e6f68013575ff4af84ef6f478fe5344317a65d38a8e6dc9", size = 43496, upload-time = "2025-06-09T22:54:09.228Z" }, @@ -1631,19 +1452,6 @@ dependencies = [ ] 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/e5/92/b31726561b5dae176c2d2c2dc43a9c5bfba5d32f96f8b4c0a600dd492447/pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8", size = 2028817, upload-time = "2025-04-23T18:30:43.919Z" }, - { url = "https://files.pythonhosted.org/packages/a3/44/3f0b95fafdaca04a483c4e685fe437c6891001bf3ce8b2fded82b9ea3aa1/pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d", size = 1861357, upload-time = "2025-04-23T18:30:46.372Z" }, - { url = "https://files.pythonhosted.org/packages/30/97/e8f13b55766234caae05372826e8e4b3b96e7b248be3157f53237682e43c/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d", size = 1898011, upload-time = "2025-04-23T18:30:47.591Z" }, - { url = "https://files.pythonhosted.org/packages/9b/a3/99c48cf7bafc991cc3ee66fd544c0aae8dc907b752f1dad2d79b1b5a471f/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572", size = 1982730, upload-time = "2025-04-23T18:30:49.328Z" }, - { url = "https://files.pythonhosted.org/packages/de/8e/a5b882ec4307010a840fb8b58bd9bf65d1840c92eae7534c7441709bf54b/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02", size = 2136178, upload-time = "2025-04-23T18:30:50.907Z" }, - { url = "https://files.pythonhosted.org/packages/e4/bb/71e35fc3ed05af6834e890edb75968e2802fe98778971ab5cba20a162315/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b", size = 2736462, upload-time = "2025-04-23T18:30:52.083Z" }, - { url = "https://files.pythonhosted.org/packages/31/0d/c8f7593e6bc7066289bbc366f2235701dcbebcd1ff0ef8e64f6f239fb47d/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2", size = 2005652, upload-time = "2025-04-23T18:30:53.389Z" }, - { url = "https://files.pythonhosted.org/packages/d2/7a/996d8bd75f3eda405e3dd219ff5ff0a283cd8e34add39d8ef9157e722867/pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a", size = 2113306, upload-time = "2025-04-23T18:30:54.661Z" }, - { url = "https://files.pythonhosted.org/packages/ff/84/daf2a6fb2db40ffda6578a7e8c5a6e9c8affb251a05c233ae37098118788/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac", size = 2073720, upload-time = "2025-04-23T18:30:56.11Z" }, - { url = "https://files.pythonhosted.org/packages/77/fb/2258da019f4825128445ae79456a5499c032b55849dbd5bed78c95ccf163/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a", size = 2244915, upload-time = "2025-04-23T18:30:57.501Z" }, - { url = "https://files.pythonhosted.org/packages/d8/7a/925ff73756031289468326e355b6fa8316960d0d65f8b5d6b3a3e7866de7/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b", size = 2241884, upload-time = "2025-04-23T18:30:58.867Z" }, - { url = "https://files.pythonhosted.org/packages/0b/b0/249ee6d2646f1cdadcb813805fe76265745c4010cf20a8eba7b0e639d9b2/pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22", size = 1910496, upload-time = "2025-04-23T18:31:00.078Z" }, - { url = "https://files.pythonhosted.org/packages/66/ff/172ba8f12a42d4b552917aa65d1f2328990d3ccfc01d5b7c943ec084299f/pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640", size = 1955019, upload-time = "2025-04-23T18:31:01.335Z" }, { 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" }, @@ -1689,15 +1497,6 @@ wheels = [ { 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/30/68/373d55e58b7e83ce371691f6eaa7175e3a24b956c44628eb25d7da007917/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa", size = 2023982, upload-time = "2025-04-23T18:32:53.14Z" }, - { url = "https://files.pythonhosted.org/packages/a4/16/145f54ac08c96a63d8ed6442f9dec17b2773d19920b627b18d4f10a061ea/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29", size = 1858412, upload-time = "2025-04-23T18:32:55.52Z" }, - { url = "https://files.pythonhosted.org/packages/41/b1/c6dc6c3e2de4516c0bb2c46f6a373b91b5660312342a0cf5826e38ad82fa/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d", size = 1892749, upload-time = "2025-04-23T18:32:57.546Z" }, - { url = "https://files.pythonhosted.org/packages/12/73/8cd57e20afba760b21b742106f9dbdfa6697f1570b189c7457a1af4cd8a0/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e", size = 2067527, upload-time = "2025-04-23T18:32:59.771Z" }, - { url = "https://files.pythonhosted.org/packages/e3/d5/0bb5d988cc019b3cba4a78f2d4b3854427fc47ee8ec8e9eaabf787da239c/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c", size = 2108225, upload-time = "2025-04-23T18:33:04.51Z" }, - { url = "https://files.pythonhosted.org/packages/f1/c5/00c02d1571913d496aabf146106ad8239dc132485ee22efe08085084ff7c/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec", size = 2069490, upload-time = "2025-04-23T18:33:06.391Z" }, - { url = "https://files.pythonhosted.org/packages/22/a8/dccc38768274d3ed3a59b5d06f59ccb845778687652daa71df0cab4040d7/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052", size = 2237525, upload-time = "2025-04-23T18:33:08.44Z" }, - { url = "https://files.pythonhosted.org/packages/d4/e7/4f98c0b125dda7cf7ccd14ba936218397b44f50a56dd8c16a3091df116c3/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c", size = 2238446, upload-time = "2025-04-23T18:33:10.313Z" }, - { url = "https://files.pythonhosted.org/packages/ce/91/2ec36480fdb0b783cd9ef6795753c1dea13882f2e68e73bce76ae8c21e6a/pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808", size = 2066678, upload-time = "2025-04-23T18:33:12.224Z" }, { 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" }, @@ -1733,12 +1532,10 @@ version = "9.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } wheels = [ @@ -1750,7 +1547,6 @@ name = "pytest-asyncio" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, { name = "pytest" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] @@ -2009,18 +1805,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/2a/9a/d451fcc97d029f5812e898fd30a53fd8c15c7bbd058fd75cfc6beb9bd761/watchfiles-1.1.0.tar.gz", hash = "sha256:693ed7ec72cbfcee399e92c895362b6e66d63dac6b91e2c11ae03d10d503e575", size = 94406, upload-time = "2025-06-15T19:06:59.42Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/dd/579d1dc57f0f895426a1211c4ef3b0cb37eb9e642bb04bdcd962b5df206a/watchfiles-1.1.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:27f30e14aa1c1e91cb653f03a63445739919aef84c8d2517997a83155e7a2fcc", size = 405757, upload-time = "2025-06-15T19:04:51.058Z" }, - { url = "https://files.pythonhosted.org/packages/1c/a0/7a0318cd874393344d48c34d53b3dd419466adf59a29ba5b51c88dd18b86/watchfiles-1.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3366f56c272232860ab45c77c3ca7b74ee819c8e1f6f35a7125556b198bbc6df", size = 397511, upload-time = "2025-06-15T19:04:52.79Z" }, - { url = "https://files.pythonhosted.org/packages/06/be/503514656d0555ec2195f60d810eca29b938772e9bfb112d5cd5ad6f6a9e/watchfiles-1.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8412eacef34cae2836d891836a7fff7b754d6bcac61f6c12ba5ca9bc7e427b68", size = 450739, upload-time = "2025-06-15T19:04:54.203Z" }, - { url = "https://files.pythonhosted.org/packages/4e/0d/a05dd9e5f136cdc29751816d0890d084ab99f8c17b86f25697288ca09bc7/watchfiles-1.1.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:df670918eb7dd719642e05979fc84704af913d563fd17ed636f7c4783003fdcc", size = 458106, upload-time = "2025-06-15T19:04:55.607Z" }, - { url = "https://files.pythonhosted.org/packages/f1/fa/9cd16e4dfdb831072b7ac39e7bea986e52128526251038eb481effe9f48e/watchfiles-1.1.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d7642b9bc4827b5518ebdb3b82698ada8c14c7661ddec5fe719f3e56ccd13c97", size = 484264, upload-time = "2025-06-15T19:04:57.009Z" }, - { url = "https://files.pythonhosted.org/packages/32/04/1da8a637c7e2b70e750a0308e9c8e662ada0cca46211fa9ef24a23937e0b/watchfiles-1.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:199207b2d3eeaeb80ef4411875a6243d9ad8bc35b07fc42daa6b801cc39cc41c", size = 597612, upload-time = "2025-06-15T19:04:58.409Z" }, - { url = "https://files.pythonhosted.org/packages/30/01/109f2762e968d3e58c95731a206e5d7d2a7abaed4299dd8a94597250153c/watchfiles-1.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a479466da6db5c1e8754caee6c262cd373e6e6c363172d74394f4bff3d84d7b5", size = 477242, upload-time = "2025-06-15T19:04:59.786Z" }, - { url = "https://files.pythonhosted.org/packages/b5/b8/46f58cf4969d3b7bc3ca35a98e739fa4085b0657a1540ccc29a1a0bc016f/watchfiles-1.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:935f9edd022ec13e447e5723a7d14456c8af254544cefbc533f6dd276c9aa0d9", size = 453148, upload-time = "2025-06-15T19:05:01.103Z" }, - { url = "https://files.pythonhosted.org/packages/a5/cd/8267594263b1770f1eb76914940d7b2d03ee55eca212302329608208e061/watchfiles-1.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:8076a5769d6bdf5f673a19d51da05fc79e2bbf25e9fe755c47595785c06a8c72", size = 626574, upload-time = "2025-06-15T19:05:02.582Z" }, - { url = "https://files.pythonhosted.org/packages/a1/2f/7f2722e85899bed337cba715723e19185e288ef361360718973f891805be/watchfiles-1.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:86b1e28d4c37e89220e924305cd9f82866bb0ace666943a6e4196c5df4d58dcc", size = 624378, upload-time = "2025-06-15T19:05:03.719Z" }, - { url = "https://files.pythonhosted.org/packages/bf/20/64c88ec43d90a568234d021ab4b2a6f42a5230d772b987c3f9c00cc27b8b/watchfiles-1.1.0-cp310-cp310-win32.whl", hash = "sha256:d1caf40c1c657b27858f9774d5c0e232089bca9cb8ee17ce7478c6e9264d2587", size = 279829, upload-time = "2025-06-15T19:05:04.822Z" }, - { url = "https://files.pythonhosted.org/packages/39/5c/a9c1ed33de7af80935e4eac09570de679c6e21c07070aa99f74b4431f4d6/watchfiles-1.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:a89c75a5b9bc329131115a409d0acc16e8da8dfd5867ba59f1dd66ae7ea8fa82", size = 292192, upload-time = "2025-06-15T19:05:06.348Z" }, { url = "https://files.pythonhosted.org/packages/8b/78/7401154b78ab484ccaaeef970dc2af0cb88b5ba8a1b415383da444cdd8d3/watchfiles-1.1.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:c9649dfc57cc1f9835551deb17689e8d44666315f2e82d337b9f07bd76ae3aa2", size = 405751, upload-time = "2025-06-15T19:05:07.679Z" }, { url = "https://files.pythonhosted.org/packages/76/63/e6c3dbc1f78d001589b75e56a288c47723de28c580ad715eb116639152b5/watchfiles-1.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:406520216186b99374cdb58bc48e34bb74535adec160c8459894884c983a149c", size = 397313, upload-time = "2025-06-15T19:05:08.764Z" }, { url = "https://files.pythonhosted.org/packages/6c/a2/8afa359ff52e99af1632f90cbf359da46184207e893a5f179301b0c8d6df/watchfiles-1.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb45350fd1dc75cd68d3d72c47f5b513cb0578da716df5fba02fff31c69d5f2d", size = 450792, upload-time = "2025-06-15T19:05:09.869Z" }, @@ -2090,10 +1874,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/69/c4/088825b75489cb5b6a761a4542645718893d395d8c530b38734f19da44d2/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d05686b5487cfa2e2c28ff1aa370ea3e6c5accfe6435944ddea1e10d93872147", size = 452240, upload-time = "2025-06-15T19:06:26.552Z" }, { url = "https://files.pythonhosted.org/packages/10/8c/22b074814970eeef43b7c44df98c3e9667c1f7bf5b83e0ff0201b0bd43f9/watchfiles-1.1.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d0e10e6f8f6dc5762adee7dece33b722282e1f59aa6a55da5d493a97282fedd8", size = 625607, upload-time = "2025-06-15T19:06:27.606Z" }, { url = "https://files.pythonhosted.org/packages/32/fa/a4f5c2046385492b2273213ef815bf71a0d4c1943b784fb904e184e30201/watchfiles-1.1.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:af06c863f152005c7592df1d6a7009c836a247c9d8adb78fef8575a5a98699db", size = 623315, upload-time = "2025-06-15T19:06:29.076Z" }, - { url = "https://files.pythonhosted.org/packages/be/7c/a3d7c55cfa377c2f62c4ae3c6502b997186bc5e38156bafcb9b653de9a6d/watchfiles-1.1.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3a6fd40bbb50d24976eb275ccb55cd1951dfb63dbc27cae3066a6ca5f4beabd5", size = 406748, upload-time = "2025-06-15T19:06:44.2Z" }, - { url = "https://files.pythonhosted.org/packages/38/d0/c46f1b2c0ca47f3667b144de6f0515f6d1c670d72f2ca29861cac78abaa1/watchfiles-1.1.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9f811079d2f9795b5d48b55a37aa7773680a5659afe34b54cc1d86590a51507d", size = 398801, upload-time = "2025-06-15T19:06:45.774Z" }, - { url = "https://files.pythonhosted.org/packages/70/9c/9a6a42e97f92eeed77c3485a43ea96723900aefa3ac739a8c73f4bff2cd7/watchfiles-1.1.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a2726d7bfd9f76158c84c10a409b77a320426540df8c35be172444394b17f7ea", size = 451528, upload-time = "2025-06-15T19:06:46.791Z" }, - { url = "https://files.pythonhosted.org/packages/51/7b/98c7f4f7ce7ff03023cf971cd84a3ee3b790021ae7584ffffa0eb2554b96/watchfiles-1.1.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df32d59cb9780f66d165a9a7a26f19df2c7d24e3bd58713108b41d0ff4f929c6", size = 454095, upload-time = "2025-06-15T19:06:48.211Z" }, { url = "https://files.pythonhosted.org/packages/8c/6b/686dcf5d3525ad17b384fd94708e95193529b460a1b7bf40851f1328ec6e/watchfiles-1.1.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0ece16b563b17ab26eaa2d52230c9a7ae46cf01759621f4fbbca280e438267b3", size = 406910, upload-time = "2025-06-15T19:06:49.335Z" }, { url = "https://files.pythonhosted.org/packages/f3/d3/71c2dcf81dc1edcf8af9f4d8d63b1316fb0a2dd90cbfd427e8d9dd584a90/watchfiles-1.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:51b81e55d40c4b4aa8658427a3ee7ea847c591ae9e8b81ef94a90b668999353c", size = 398816, upload-time = "2025-06-15T19:06:50.433Z" }, { url = "https://files.pythonhosted.org/packages/b8/fa/12269467b2fc006f8fce4cd6c3acfa77491dd0777d2a747415f28ccc8c60/watchfiles-1.1.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2bcdc54ea267fe72bfc7d83c041e4eb58d7d8dc6f578dfddb52f037ce62f432", size = 451584, upload-time = "2025-06-15T19:06:51.834Z" }, @@ -2111,23 +1891,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/3c/fb/efaa23fa4e45537b827620f04cf8f3cd658b76642205162e072703a5b963/yarl-1.20.1.tar.gz", hash = "sha256:d017a4997ee50c91fd5466cef416231bb82177b93b029906cefc542ce14c35ac", size = 186428, upload-time = "2025-06-10T00:46:09.923Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/65/7fed0d774abf47487c64be14e9223749468922817b5e8792b8a64792a1bb/yarl-1.20.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6032e6da6abd41e4acda34d75a816012717000fa6839f37124a47fcefc49bec4", size = 132910, upload-time = "2025-06-10T00:42:31.108Z" }, - { url = "https://files.pythonhosted.org/packages/8a/7b/988f55a52da99df9e56dc733b8e4e5a6ae2090081dc2754fc8fd34e60aa0/yarl-1.20.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2c7b34d804b8cf9b214f05015c4fee2ebe7ed05cf581e7192c06555c71f4446a", size = 90644, upload-time = "2025-06-10T00:42:33.851Z" }, - { url = "https://files.pythonhosted.org/packages/f7/de/30d98f03e95d30c7e3cc093759982d038c8833ec2451001d45ef4854edc1/yarl-1.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0c869f2651cc77465f6cd01d938d91a11d9ea5d798738c1dc077f3de0b5e5fed", size = 89322, upload-time = "2025-06-10T00:42:35.688Z" }, - { url = "https://files.pythonhosted.org/packages/e0/7a/f2f314f5ebfe9200724b0b748de2186b927acb334cf964fd312eb86fc286/yarl-1.20.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62915e6688eb4d180d93840cda4110995ad50c459bf931b8b3775b37c264af1e", size = 323786, upload-time = "2025-06-10T00:42:37.817Z" }, - { url = "https://files.pythonhosted.org/packages/15/3f/718d26f189db96d993d14b984ce91de52e76309d0fd1d4296f34039856aa/yarl-1.20.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:41ebd28167bc6af8abb97fec1a399f412eec5fd61a3ccbe2305a18b84fb4ca73", size = 319627, upload-time = "2025-06-10T00:42:39.937Z" }, - { url = "https://files.pythonhosted.org/packages/a5/76/8fcfbf5fa2369157b9898962a4a7d96764b287b085b5b3d9ffae69cdefd1/yarl-1.20.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:21242b4288a6d56f04ea193adde174b7e347ac46ce6bc84989ff7c1b1ecea84e", size = 339149, upload-time = "2025-06-10T00:42:42.627Z" }, - { url = "https://files.pythonhosted.org/packages/3c/95/d7fc301cc4661785967acc04f54a4a42d5124905e27db27bb578aac49b5c/yarl-1.20.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bea21cdae6c7eb02ba02a475f37463abfe0a01f5d7200121b03e605d6a0439f8", size = 333327, upload-time = "2025-06-10T00:42:44.842Z" }, - { url = "https://files.pythonhosted.org/packages/65/94/e21269718349582eee81efc5c1c08ee71c816bfc1585b77d0ec3f58089eb/yarl-1.20.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f8a891e4a22a89f5dde7862994485e19db246b70bb288d3ce73a34422e55b23", size = 326054, upload-time = "2025-06-10T00:42:47.149Z" }, - { url = "https://files.pythonhosted.org/packages/32/ae/8616d1f07853704523519f6131d21f092e567c5af93de7e3e94b38d7f065/yarl-1.20.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dd803820d44c8853a109a34e3660e5a61beae12970da479cf44aa2954019bf70", size = 315035, upload-time = "2025-06-10T00:42:48.852Z" }, - { url = "https://files.pythonhosted.org/packages/48/aa/0ace06280861ef055855333707db5e49c6e3a08840a7ce62682259d0a6c0/yarl-1.20.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b982fa7f74c80d5c0c7b5b38f908971e513380a10fecea528091405f519b9ebb", size = 338962, upload-time = "2025-06-10T00:42:51.024Z" }, - { url = "https://files.pythonhosted.org/packages/20/52/1e9d0e6916f45a8fb50e6844f01cb34692455f1acd548606cbda8134cd1e/yarl-1.20.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:33f29ecfe0330c570d997bcf1afd304377f2e48f61447f37e846a6058a4d33b2", size = 335399, upload-time = "2025-06-10T00:42:53.007Z" }, - { url = "https://files.pythonhosted.org/packages/f2/65/60452df742952c630e82f394cd409de10610481d9043aa14c61bf846b7b1/yarl-1.20.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:835ab2cfc74d5eb4a6a528c57f05688099da41cf4957cf08cad38647e4a83b30", size = 338649, upload-time = "2025-06-10T00:42:54.964Z" }, - { url = "https://files.pythonhosted.org/packages/7b/f5/6cd4ff38dcde57a70f23719a838665ee17079640c77087404c3d34da6727/yarl-1.20.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:46b5e0ccf1943a9a6e766b2c2b8c732c55b34e28be57d8daa2b3c1d1d4009309", size = 358563, upload-time = "2025-06-10T00:42:57.28Z" }, - { url = "https://files.pythonhosted.org/packages/d1/90/c42eefd79d0d8222cb3227bdd51b640c0c1d0aa33fe4cc86c36eccba77d3/yarl-1.20.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:df47c55f7d74127d1b11251fe6397d84afdde0d53b90bedb46a23c0e534f9d24", size = 357609, upload-time = "2025-06-10T00:42:59.055Z" }, - { url = "https://files.pythonhosted.org/packages/03/c8/cea6b232cb4617514232e0f8a718153a95b5d82b5290711b201545825532/yarl-1.20.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:76d12524d05841276b0e22573f28d5fbcb67589836772ae9244d90dd7d66aa13", size = 350224, upload-time = "2025-06-10T00:43:01.248Z" }, - { url = "https://files.pythonhosted.org/packages/ce/a3/eaa0ab9712f1f3d01faf43cf6f1f7210ce4ea4a7e9b28b489a2261ca8db9/yarl-1.20.1-cp310-cp310-win32.whl", hash = "sha256:6c4fbf6b02d70e512d7ade4b1f998f237137f1417ab07ec06358ea04f69134f8", size = 81753, upload-time = "2025-06-10T00:43:03.486Z" }, - { url = "https://files.pythonhosted.org/packages/8f/34/e4abde70a9256465fe31c88ed02c3f8502b7b5dead693a4f350a06413f28/yarl-1.20.1-cp310-cp310-win_amd64.whl", hash = "sha256:aef6c4d69554d44b7f9d923245f8ad9a707d971e6209d51279196d8e8fe1ae16", size = 86817, upload-time = "2025-06-10T00:43:05.231Z" }, { url = "https://files.pythonhosted.org/packages/b1/18/893b50efc2350e47a874c5c2d67e55a0ea5df91186b2a6f5ac52eff887cd/yarl-1.20.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:47ee6188fea634bdfaeb2cc420f5b3b17332e6225ce88149a17c413c77ff269e", size = 133833, upload-time = "2025-06-10T00:43:07.393Z" }, { url = "https://files.pythonhosted.org/packages/89/ed/b8773448030e6fc47fa797f099ab9eab151a43a25717f9ac043844ad5ea3/yarl-1.20.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d0f6500f69e8402d513e5eedb77a4e1818691e8f45e6b687147963514d84b44b", size = 91070, upload-time = "2025-06-10T00:43:09.538Z" }, { url = "https://files.pythonhosted.org/packages/e3/e3/409bd17b1e42619bf69f60e4f031ce1ccb29bd7380117a55529e76933464/yarl-1.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7a8900a42fcdaad568de58887c7b2f602962356908eedb7628eaf6021a6e435b", size = 89818, upload-time = "2025-06-10T00:43:11.575Z" }, From fafb1baffa3394169d637db6d98e987b54e8f832 Mon Sep 17 00:00:00 2001 From: timo Date: Thu, 11 Jun 2026 11:32:45 +0200 Subject: [PATCH 03/11] fix(voxtral): reduce word loss at max-buffer segment boundaries Words spoken across the forced 8 s segment split are lost because the cut lands mid-word and neither segment has enough audio context to transcribe it correctly. Two changes address this: - Raise the default max-buffer cap from 8 s to 30 s so forced splits are rare in practice; natural pauses detected by the VAD close segments cleanly before the cap is reached. - Keep a rolling pre-roll across segment boundaries. Previously the pre-roll was cleared on close, so the next segment started cold. Now the last ~0.5 s is replayed as the lead-in of the following segment, giving the model enough context to recover boundary words. The pre-roll length is clamped to min_silence_duration to prevent a normal utterance onset from replaying the previous utterance's tail. --- .env.example | 7 +++++-- providers/voxtral_realtime.py | 36 ++++++++++++++++++++++++++--------- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/.env.example b/.env.example index 600d65b..92ab4ae 100644 --- a/.env.example +++ b/.env.example @@ -95,8 +95,11 @@ GLADIA_TRANSLATION_LANG_MAP="de:de-DE,en:en-US,es:es-ES,fr:fr-FR,hi:hi-IN,it:it- # word onset preceding Silero's detection is not lost (default: 0.5) #VOXTRAL_VAD_PREROLL_S=0.5 -# Maximum speech segment duration in seconds before a forced flush (default: 8.0) -#VOXTRAL_MAX_BUFFER_DURATION_S=8.0 +# Safety cap on a single streaming request before a forced split (default: 30.0). +# Only reached during uninterrupted monologue; each split risks clipping a word, +# so keep it high. Lower it to commit final transcripts sooner during long +# continuous speech (live interim captions are unaffected). +#VOXTRAL_MAX_BUFFER_DURATION_S=30.0 # Target sample rate required by the model in Hz (default: 16000) #VOXTRAL_TARGET_SAMPLE_RATE=16000 diff --git a/providers/voxtral_realtime.py b/providers/voxtral_realtime.py index fb8621a..0be95a3 100644 --- a/providers/voxtral_realtime.py +++ b/providers/voxtral_realtime.py @@ -26,7 +26,11 @@ from providers.base import BaseSttAgent, BaseSttConfig -_MAX_BUFFER_DURATION_S = float(os.getenv("VOXTRAL_MAX_BUFFER_DURATION_S", "8.0")) +# Safety cap on a single streaming request's length. Only hit during pure +# monologue (no VAD pause); each cap-triggered split risks cutting a word, so it +# is set high to make splits rare. Lower it only to commit FINAL transcripts +# sooner during long continuous speech (interim captions stream regardless). +_MAX_BUFFER_DURATION_S = float(os.getenv("VOXTRAL_MAX_BUFFER_DURATION_S", "30.0")) _TARGET_SAMPLE_RATE = int(os.getenv("VOXTRAL_TARGET_SAMPLE_RATE", "16000")) # Silero VAD parameters — replace the old RMS threshold and silence duration _VAD_MIN_SILENCE_S = float(os.getenv("VOXTRAL_VAD_MIN_SILENCE_S", "0.6")) @@ -412,8 +416,12 @@ async def _writer() -> None: # owned exclusively by _vad_task. Keeping them separate is what lets a # max-buffer split mid-utterance reopen immediately on the next frame # (we never clobber the VAD's view of whether speech is ongoing). - preroll_max = int(_VAD_PREROLL_S * _TARGET_SAMPLE_RATE) * 2 # int16 bytes - preroll = bytearray() # recent audio captured while no request is open + # Pre-roll must not exceed the VAD's min-silence: otherwise a normal + # onset could replay the previous utterance's tail (it would not have + # rolled out of the buffer during the gap) and duplicate it. + preroll_secs_max = min(_VAD_PREROLL_S, _VAD_MIN_SILENCE_S) + preroll_max = int(preroll_secs_max * _TARGET_SAMPLE_RATE) * 2 # int16 bytes + preroll = bytearray() # rolling window of the most recent audio pending = b"" # audio buffered for chunked append while open open_secs = 0.0 # duration of the current open segment stream_open = False @@ -457,7 +465,10 @@ async def _close() -> None: await ws.send_json({"type": "input_audio_buffer.commit"}) await ws.send_json({"type": "input_audio_buffer.commit", "final": True}) stream_open = False - preroll.clear() # committed; pre-roll only seeds the next onset + # Pre-roll is intentionally NOT cleared: if this was a max-buffer + # split, the next frame reopens immediately and replays the last + # ~0.5 s as overlap so the boundary word is not lost. _open() + # clears it after replaying. async for audio_event in audio_stream: frame = audio_event.frame @@ -480,11 +491,18 @@ async def _close() -> None: pending += resampled open_secs += frame_duration await _flush_pending() - else: - # Idle: keep only a bounded rolling pre-roll; send nothing. - preroll += resampled - if len(preroll) > preroll_max: - del preroll[:-preroll_max] + + # Keep a bounded rolling pre-roll of the most recent audio, + # whether idle or open. _open() replays it as the segment's + # lead-in: on a fresh onset that is the audio before the VAD + # fired; on a max-buffer split it is the overlap that carries + # the boundary word fully into the next segment instead of + # cutting it in half. Inter-utterance silence (>= Silero's + # min_silence, which exceeds the pre-roll length) flushes stale + # speech, so a normal onset never replays a prior utterance. + preroll += resampled + if len(preroll) > preroll_max: + del preroll[:-preroll_max] # Close on end-of-speech (Silero) or the max-buffer safety cap. # is_in_speech is deliberately left untouched: if the speaker is From 65f12ed1ff89d2cd10278e6d74aac80a971a28ca Mon Sep 17 00:00:00 2001 From: timo Date: Tue, 7 Jul 2026 14:14:24 +0200 Subject: [PATCH 04/11] fix(voxtral): recover from reader failures and flush segments on teardown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three paths silently lose utterances, all presenting as captions that get an interim (or nothing) and never a final: 1. When the server sends an `error` event or the receive loop fails, the reader task exits while the WebSocket stays alive. The writer keeps appending audio into a session nobody reads, so every subsequent utterance is lost with no log and no recovery until the track cycles. 2. When a speaker mutes or unpublishes right after talking, frames stop before Silero accumulates the 0.6 s of silence needed to fire END_OF_SPEECH. Task cancellation then abandons the open segment: no closing commit is sent (abrupt drops are a known vLLM realtime crash trigger, vllm#34532), the accumulated delta text is discarded, and BBB shows the interim caption as pending forever. Reconnects mid-utterance lose the segment the same way. 3. Even on a clean stream end, teardown cancels the reader immediately after the writer flushes, so the tail segment's transcription.done is never read and its FINAL is dropped. Fix each at the point where the information still exists: - The reader closes the socket when it exits for any reason other than cancellation. The writer's next send then fails and the existing reconnect/backoff path takes over, turning silent death into recovery. The pipeline also catches ConnectionResetError, since a send on a locally-closed socket can surface as that instead of ClientError. - The writer catches CancelledError and best-effort sends the closing commit (1 s cap) for an open segment before propagating. - The in-flight segment's delta text is hoisted to _vad_loop scope so teardown can emit a FINAL from the best available text whenever the real transcription.done will never arrive — converting "caption lost" into "final from partial data" on every loss path at once. - After a clean writer exit, a bounded drain (3 s) lets the reader consume the tail segment's transcription.done before cancellation. An alternative for (1) — a cross-task signal so reconnection does not wait for the writer's next send — was discarded as added coupling for a rare event; the cost is only a degraded onset for the utterance that triggers the reconnect. The duplicated SpeechEvent construction collapses into a single _emit_transcript helper, and the reconnect backoff literals become module constants so the new reconnect regression test does not need a real 1 s sleep. Regression tests cover all three paths via a scripted WebSocket double whose sends fail once closed, mirroring aiohttp. --- CHANGELOG.md | 4 + providers/voxtral_realtime.py | 243 ++++++++++++++++----------- tests/test_voxtral_agent.py | 299 ++++++++++++++++++++++++++++++++-- 3 files changed, 438 insertions(+), 108 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa8bbf4..d339226 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ Final releases will consolidate all intermediate changes in chronological order. * feat(tests): add unit and integration tests with pytest * feat(tests): add coverage reporting with pytest-cov * feat(tests): add tests for v0.2.0 changes (utils coercions, config redaction, on_track_subscribed fix, new defaults) +* feat(voxtral): Voxtral Realtime STT provider with concurrent streaming +* feat(voxtral): replace RMS VAD with Silero neural VAD on Python 3.11 +* fix(voxtral): reduce word loss at max-buffer segment boundaries +* fix(voxtral): recover from reader failures and flush segments on teardown * build: add GitHub Actions workflow for running tests ## v0.2.0 diff --git a/providers/voxtral_realtime.py b/providers/voxtral_realtime.py index 0be95a3..30d5e27 100644 --- a/providers/voxtral_realtime.py +++ b/providers/voxtral_realtime.py @@ -38,6 +38,13 @@ # Rolling pre-roll kept while idle so the word onset preceding Silero's # START_OF_SPEECH (its prefix padding) is sent once a streaming request opens. _VAD_PREROLL_S = float(os.getenv("VOXTRAL_VAD_PREROLL_S", "0.5")) +# Reconnect backoff bounds for a dropped WebSocket connection. +_RETRY_DELAY_INITIAL_S = 1.0 +_RETRY_DELAY_MAX_S = 30.0 +# After the audio stream ends, wait up to this long for the server's +# transcription.done of the final segment before tearing the reader down; +# cancelling it immediately would drop the tail utterance's FINAL. +_FINAL_DRAIN_TIMEOUT_S = 3.0 @dataclass @@ -145,7 +152,7 @@ async def _run_transcription_pipeline( ws_url = self._build_ws_url() headers = {"Authorization": f"Bearer {self.config.api_key}"} open_time = time.time() - retry_delay = 1.0 + retry_delay = _RETRY_DELAY_INITIAL_S try: while True: @@ -170,7 +177,7 @@ async def _run_transcription_pipeline( f"Voxtral WS session created for {participant.identity}" ) # Connection is healthy again; reset reconnect backoff. - retry_delay = 1.0 + retry_delay = _RETRY_DELAY_INITIAL_S # vLLM expects model at top level of session.update await ws.send_json( @@ -184,13 +191,13 @@ async def _run_transcription_pipeline( except asyncio.CancelledError: raise - except aiohttp.ClientError as e: + except (aiohttp.ClientError, ConnectionResetError) as e: logging.warning( f"Voxtral WS connection lost for {participant.identity} " f"({type(e).__name__}: {e}), reconnecting in {retry_delay:.0f}s" ) await asyncio.sleep(retry_delay) - retry_delay = min(retry_delay * 2, 30.0) + retry_delay = min(retry_delay * 2, _RETRY_DELAY_MAX_S) except Exception as e: logging.error( f"Voxtral Realtime error for {participant.identity}: {e}", @@ -228,6 +235,36 @@ async def _vad_loop( # variable would collide and make segment 2 overwrite segment 1). segment_starts: deque[float] = deque() + # In-flight segment state. Owned by _reader, but hoisted to _vad_loop + # scope so teardown can emit a best-effort FINAL from whatever delta + # text was received when the segment's transcription.done never comes + # (cancellation, reconnect, reader death). Without that, BBB keeps + # showing the already-emitted interim as pending forever. + seg_text = "" + seg_start: float | None = None + + def _emit_transcript(final: bool, text: str, start_time: float) -> None: + self.emit( + "final_transcript" if final else "interim_transcript", + participant=participant, + event=stt.SpeechEvent( + type=( + stt.SpeechEventType.FINAL_TRANSCRIPT + if final + else stt.SpeechEventType.INTERIM_TRANSCRIPT + ), + alternatives=[ + stt.SpeechData( + text=text, + language=language, + start_time=start_time, + end_time=time.time() - open_time, + ) + ], + ), + open_time=open_time, + ) + # ── Reader ──────────────────────────────────────────────────────────── async def _reader() -> None: @@ -237,8 +274,7 @@ async def _reader() -> None: emitted by the server while audio is still streaming are consumed in real time rather than buffered and replayed after each commit. """ - text = "" - utterance_start: float | None = None + nonlocal seg_text, seg_start while True: try: @@ -280,9 +316,9 @@ async def _reader() -> None: msg_type = data.get("type") if msg_type == "transcription.delta": - if utterance_start is None: + if seg_start is None: # Pair this segment with the start time its opener pushed. - utterance_start = ( + seg_start = ( segment_starts.popleft() if segment_starts else time.time() - open_time @@ -290,26 +326,11 @@ async def _reader() -> None: logging.debug( f"Voxtral: first delta for {participant.identity} " f"at t={time.time() - open_time:.3f}s " - f"(utterance_start={utterance_start:.3f}s)" - ) - text += data.get("delta", "") - if text and self.config.interim_results: - self.emit( - "interim_transcript", - participant=participant, - event=stt.SpeechEvent( - type=stt.SpeechEventType.INTERIM_TRANSCRIPT, - alternatives=[ - stt.SpeechData( - text=text, - language=language, - start_time=utterance_start, - end_time=time.time() - open_time, - ) - ], - ), - open_time=open_time, + f"(seg_start={seg_start:.3f}s)" ) + seg_text += data.get("delta", "") + if seg_text and self.config.interim_results: + _emit_transcript(False, seg_text, seg_start) elif msg_type == "transcription.done": # Prefer accumulated delta text over done.text: the realtime @@ -318,46 +339,38 @@ async def _reader() -> None: logging.debug( f"Voxtral: transcription.done for {participant.identity} " f"at t={time.time() - open_time:.3f}s — " - f"delta_text='{text[:60]}', server_text='{server_text[:60]}'" + f"delta_text='{seg_text[:60]}', server_text='{server_text[:60]}'" ) if server_text: - text = server_text + seg_text = server_text - if utterance_start is None: + if seg_start is None: # Zero-delta segment: consume its queued start anyway so # later segments stay paired with their own openers. - utterance_start = ( + seg_start = ( segment_starts.popleft() if segment_starts else time.time() - open_time ) - if text: - self.emit( - "final_transcript", - participant=participant, - event=stt.SpeechEvent( - type=stt.SpeechEventType.FINAL_TRANSCRIPT, - alternatives=[ - stt.SpeechData( - text=text, - language=language, - start_time=utterance_start, - end_time=time.time() - open_time, - ) - ], - ), - open_time=open_time, - ) + if seg_text: + _emit_transcript(True, seg_text, seg_start) # Reset for next utterance - text = "" - utterance_start = None + seg_text = "" + seg_start = None elif msg_type == "error": logging.error(f"Voxtral WS error event: {data}") break + # Reaching here means the reader stopped while the writer may still + # be streaming (error event, receive failure, server close). Close + # the socket so the writer's next send fails and the pipeline's + # reconnect logic takes over — otherwise audio keeps flowing into a + # session nobody reads and transcription dies silently. + await ws.close() + # ── VAD task + Writer (Silero VAD, server-side streaming) ──────────── # # Protocol (verified empirically against vLLM Voxtral, see @@ -398,9 +411,7 @@ async def _vad_task() -> None: elif ev.type == agents_vad.VADEventType.END_OF_SPEECH: is_in_speech = False commit_event.set() - logging.debug( - f"Voxtral: speech end for {participant.identity}" - ) + logging.debug(f"Voxtral: speech end for {participant.identity}") except asyncio.CancelledError: raise except Exception as e: @@ -470,48 +481,65 @@ async def _close() -> None: # ~0.5 s as overlap so the boundary word is not lost. _open() # clears it after replaying. - async for audio_event in audio_stream: - frame = audio_event.frame - frame_duration = frame.samples_per_channel / frame.sample_rate - - # Feed the VAD, then yield once so _vad_task is scheduled. In - # production the network awaits below also yield; this keeps the - # interleave deterministic and lets tests drive a sync frame - # iterator without starving the VAD task. - vad_stream.push_frame(frame) - await asyncio.sleep(0) - - resampled = _to_pcm16_16k(frame) - - # Open a request as soon as the VAD reports speech. - if is_in_speech and not stream_open: - await _open() - + try: + async for audio_event in audio_stream: + frame = audio_event.frame + frame_duration = frame.samples_per_channel / frame.sample_rate + + # Feed the VAD, then yield once so _vad_task is scheduled. In + # production the network awaits below also yield; this keeps the + # interleave deterministic and lets tests drive a sync frame + # iterator without starving the VAD task. + vad_stream.push_frame(frame) + await asyncio.sleep(0) + + resampled = _to_pcm16_16k(frame) + + # Open a request as soon as the VAD reports speech. + if is_in_speech and not stream_open: + await _open() + + if stream_open: + pending += resampled + open_secs += frame_duration + await _flush_pending() + + # Keep a bounded rolling pre-roll of the most recent audio, + # whether idle or open. _open() replays it as the segment's + # lead-in: on a fresh onset that is the audio before the VAD + # fired; on a max-buffer split it is the overlap that carries + # the boundary word fully into the next segment instead of + # cutting it in half. Inter-utterance silence (>= Silero's + # min_silence, which exceeds the pre-roll length) flushes stale + # speech, so a normal onset never replays a prior utterance. + preroll += resampled + if len(preroll) > preroll_max: + del preroll[:-preroll_max] + + # Close on end-of-speech (Silero) or the max-buffer safety cap. + # is_in_speech is deliberately left untouched: if the speaker is + # still talking past the cap, the next frame reopens immediately. + if stream_open and ( + commit_event.is_set() or open_secs >= _MAX_BUFFER_DURATION_S + ): + commit_event.clear() + await _close() + except asyncio.CancelledError: + # Frames stop before Silero can fire END_OF_SPEECH when a + # speaker mutes or unpublishes right after talking, so an open + # request would be dropped without its closing commit — losing + # the utterance's FINAL and leaving the server request dangling + # (abrupt drops are a known vLLM realtime crash trigger). + # Best-effort close before propagating the cancellation. if stream_open: - pending += resampled - open_secs += frame_duration - await _flush_pending() - - # Keep a bounded rolling pre-roll of the most recent audio, - # whether idle or open. _open() replays it as the segment's - # lead-in: on a fresh onset that is the audio before the VAD - # fired; on a max-buffer split it is the overlap that carries - # the boundary word fully into the next segment instead of - # cutting it in half. Inter-utterance silence (>= Silero's - # min_silence, which exceeds the pre-roll length) flushes stale - # speech, so a normal onset never replays a prior utterance. - preroll += resampled - if len(preroll) > preroll_max: - del preroll[:-preroll_max] - - # Close on end-of-speech (Silero) or the max-buffer safety cap. - # is_in_speech is deliberately left untouched: if the speaker is - # still talking past the cap, the next frame reopens immediately. - if stream_open and ( - commit_event.is_set() or open_secs >= _MAX_BUFFER_DURATION_S - ): - commit_event.clear() - await _close() + try: + await asyncio.wait_for(_close(), timeout=1.0) + except Exception: + logging.debug( + f"Voxtral: cancel-time flush failed for " + f"{participant.identity}" + ) + raise # End of stream: flush any open request. if stream_open: @@ -522,13 +550,40 @@ async def _close() -> None: reader_task = asyncio.create_task(_reader()) vad_task = asyncio.create_task(_vad_task()) + + async def _drain_reader() -> None: + """Wait (bounded) for the reader to consume the final segment's + transcription.done after the writer finishes cleanly.""" + loop = asyncio.get_running_loop() + deadline = loop.time() + _FINAL_DRAIN_TIMEOUT_S + while ( + (segment_starts or seg_text) + and not reader_task.done() + and loop.time() < deadline + ): + await asyncio.sleep(0.05) + try: await _writer() + await _drain_reader() finally: reader_task.cancel() vad_task.cancel() await asyncio.gather(reader_task, vad_task, return_exceptions=True) await vad_stream.aclose() + if seg_text: + # Teardown caught a segment mid-flight: its transcription.done + # will never be read, and BBB would show the already-emitted + # interim as pending forever. Commit the best text we have. + logging.info( + f"Voxtral: emitting best-effort FINAL for " + f"{participant.identity} on teardown: '{seg_text[:60]}'" + ) + _emit_transcript( + True, + seg_text, + seg_start if seg_start is not None else time.time() - open_time, + ) def _to_pcm16_16k(frame: rtc.AudioFrame) -> bytes: diff --git a/tests/test_voxtral_agent.py b/tests/test_voxtral_agent.py index 2de3f54..fc71a37 100644 --- a/tests/test_voxtral_agent.py +++ b/tests/test_voxtral_agent.py @@ -40,7 +40,9 @@ def _make_config(**kwargs): def _make_agent(vad_events=None, **kwargs): - return VoxtralRealtimeSttAgent(_make_config(**kwargs), vad=_make_mock_vad(vad_events)) + return VoxtralRealtimeSttAgent( + _make_config(**kwargs), vad=_make_mock_vad(vad_events) + ) def _make_participant(identity, has_audio_track=True): @@ -99,11 +101,18 @@ def _make_loud_frame(): class TestVoxtralRealtimeConfig: @pytest.fixture(autouse=True) def _clean_env(self, monkeypatch): - for key in ["VOXTRAL_API_KEY", "VOXTRAL_MODEL", "VOXTRAL_BASE_URL", "VOXTRAL_INTERIM_RESULTS"]: + for key in [ + "VOXTRAL_API_KEY", + "VOXTRAL_MODEL", + "VOXTRAL_BASE_URL", + "VOXTRAL_INTERIM_RESULTS", + ]: monkeypatch.delenv(key, raising=False) def test_default_model(self): - assert VoxtralRealtimeConfig().model == "mistralai/Voxtral-Mini-4B-Realtime-2602" + assert ( + VoxtralRealtimeConfig().model == "mistralai/Voxtral-Mini-4B-Realtime-2602" + ) def test_default_api_key_is_none(self): assert VoxtralRealtimeConfig().api_key is None @@ -141,7 +150,10 @@ def test_custom_base_url_via_env(self, monkeypatch): class TestBuildWsUrl: def test_default_url(self): agent = _make_agent() - assert agent._build_ws_url() == "wss://api.openai.com/v1/realtime?intent=transcription" + assert ( + agent._build_ws_url() + == "wss://api.openai.com/v1/realtime?intent=transcription" + ) def test_custom_https_url_becomes_wss(self): agent = _make_agent(base_url="https://my-server.example.com/v1") @@ -210,7 +222,9 @@ class TestStartTranscriptionForUser: def test_participant_not_found_logs_error(self, caplog): agent = _make_agent_with_room(participants={}) with caplog.at_level("ERROR"): - agent.start_transcription_for_user("ghost_user", "en-US", "voxtral-realtime") + agent.start_transcription_for_user( + "ghost_user", "en-US", "voxtral-realtime" + ) assert "ghost_user" in caplog.text assert "ghost_user" not in agent.processing_info @@ -247,7 +261,9 @@ async def test_locale_is_sanitized_to_language_code(self): participant = _make_participant("user_1") agent = _make_agent_with_room(participants={"p1": participant}) - with patch.object(agent, "_run_transcription_pipeline", new_callable=AsyncMock) as mock_pipeline: + with patch.object( + agent, "_run_transcription_pipeline", new_callable=AsyncMock + ) as mock_pipeline: agent.start_transcription_for_user("user_1", "pt-BR", "voxtral-realtime") await asyncio.sleep(0) @@ -321,7 +337,9 @@ async def test_exits_cleanly_on_non_text_first_message(self, caplog): mock_stream.__aiter__.return_value = iter([]) mock_stream.aclose = AsyncMock() - with patch("providers.voxtral_realtime.rtc.AudioStream", return_value=mock_stream): + with patch( + "providers.voxtral_realtime.rtc.AudioStream", return_value=mock_stream + ): await agent._run_transcription_pipeline(participant, MagicMock(), "en") assert "user_1" not in agent.processing_info @@ -339,7 +357,9 @@ async def test_exits_cleanly_on_wrong_first_message_type(self, caplog): mock_stream.__aiter__.return_value = iter([]) mock_stream.aclose = AsyncMock() - with patch("providers.voxtral_realtime.rtc.AudioStream", return_value=mock_stream): + with patch( + "providers.voxtral_realtime.rtc.AudioStream", return_value=mock_stream + ): await agent._run_transcription_pipeline(participant, MagicMock(), "en") assert "user_1" not in agent.processing_info @@ -376,6 +396,7 @@ def _full_pipeline_setup(self, audio_frames, ws_messages, vad_events=None): mock_ws = AsyncMock() mock_ws.receive = AsyncMock(side_effect=all_ws) + # Use a real async function so that awaiting send_json actually yields # to the event loop — this gives the concurrent _reader() task a chance # to process incoming WS messages while the writer is still sending. @@ -410,7 +431,9 @@ async def test_silent_frames_do_not_trigger_flush(self): emitted = [] agent.on("final_transcript", lambda **kw: emitted.append(kw)) - with patch("providers.voxtral_realtime.rtc.AudioStream", return_value=mock_stream): + with patch( + "providers.voxtral_realtime.rtc.AudioStream", return_value=mock_stream + ): await agent._run_transcription_pipeline(participant, MagicMock(), "en") assert emitted == [] @@ -432,7 +455,9 @@ async def test_loud_frame_followed_by_end_of_stream_emits_final(self): emitted = [] agent.on("final_transcript", lambda **kw: emitted.append(kw)) - with patch("providers.voxtral_realtime.rtc.AudioStream", return_value=mock_stream): + with patch( + "providers.voxtral_realtime.rtc.AudioStream", return_value=mock_stream + ): await agent._run_transcription_pipeline(participant, MagicMock(), "en") await asyncio.sleep(0) @@ -457,7 +482,9 @@ async def test_interim_deltas_emitted_during_flush(self): agent.on("interim_transcript", lambda **kw: interim.append(kw)) agent.on("final_transcript", lambda **kw: final.append(kw)) - with patch("providers.voxtral_realtime.rtc.AudioStream", return_value=mock_stream): + with patch( + "providers.voxtral_realtime.rtc.AudioStream", return_value=mock_stream + ): await agent._run_transcription_pipeline(participant, MagicMock(), "en") await asyncio.sleep(0) @@ -492,7 +519,9 @@ async def test_two_utterances_emit_two_finals_with_independent_text(self): agent.on("interim_transcript", lambda **kw: interim.append(kw)) agent.on("final_transcript", lambda **kw: final.append(kw)) - with patch("providers.voxtral_realtime.rtc.AudioStream", return_value=mock_stream): + with patch( + "providers.voxtral_realtime.rtc.AudioStream", return_value=mock_stream + ): await agent._run_transcription_pipeline(participant, MagicMock(), "en") await asyncio.sleep(0) @@ -529,7 +558,9 @@ async def test_all_events_of_an_utterance_share_start_time(self): agent.on("interim_transcript", lambda **kw: interim.append(kw)) agent.on("final_transcript", lambda **kw: final.append(kw)) - with patch("providers.voxtral_realtime.rtc.AudioStream", return_value=mock_stream): + with patch( + "providers.voxtral_realtime.rtc.AudioStream", return_value=mock_stream + ): await agent._run_transcription_pipeline(participant, MagicMock(), "en") await asyncio.sleep(0) @@ -585,7 +616,9 @@ async def _send_json(data): mock_stream.__aiter__.return_value = iter(audio_events) mock_stream.aclose = AsyncMock() - with patch("providers.voxtral_realtime.rtc.AudioStream", return_value=mock_stream): + with patch( + "providers.voxtral_realtime.rtc.AudioStream", return_value=mock_stream + ): await agent._run_transcription_pipeline(participant, MagicMock(), "en") commits = [m for m in sent if m.get("type") == "input_audio_buffer.commit"] @@ -722,3 +755,241 @@ async def _receive(): f"(distinct BBB transcriptIds) — got {starts}; equal values mean " f"segment 2 overwrites segment 1 in the transcript" ) + + +# ── Failure recovery and teardown flush ──────────────────────────────────────── + + +class _ScriptedWs: + """Minimal WS double: serves a fixed message list, then blocks until closed. + + send_json raises ClientError once the socket is closed — mirroring aiohttp, + so the writer's send failure is what surfaces a reader-initiated close. + """ + + def __init__(self, messages): + self._messages = list(messages) + self.closed = False + self.sent: list[dict] = [] + + async def receive(self, *args, **kwargs): + await asyncio.sleep(0) + if self._messages: + return self._messages.pop(0) + while not self.closed: + await asyncio.sleep(0) + msg = MagicMock() + msg.type = aiohttp.WSMsgType.CLOSED + return msg + + async def send_json(self, data): + if self.closed: + raise aiohttp.ClientError("socket closed") + self.sent.append(data) + await asyncio.sleep(0) + + async def close(self): + self.closed = True + + +class _EndlessAudioStream: + """Async audio stream that yields loud frames until abandoned.""" + + def __aiter__(self): + return self + + async def __anext__(self): + await asyncio.sleep(0) + return MagicMock(frame=_make_loud_frame()) + + async def aclose(self): + pass + + +def _ws_context(ws): + cm = AsyncMock() + cm.__aenter__ = AsyncMock(return_value=ws) + cm.__aexit__ = AsyncMock(return_value=False) + return cm + + +class TestReaderFailureRecovery: + async def test_server_error_event_closes_ws_and_reconnects(self, monkeypatch): + """ + Regression for silent transcription death: a server `error` event makes + the reader exit while the connection stays alive. The reader must close + the socket so the writer's sends fail and the pipeline reconnects — + otherwise audio keeps streaming into a session nobody reads and every + subsequent utterance is lost without a trace. + """ + import providers.voxtral_realtime as vr + + monkeypatch.setattr(vr, "_RETRY_DELAY_INITIAL_S", 0.01) + + agent = _make_agent( + vad_events=[_make_vad_event(agents_vad.VADEventType.START_OF_SPEECH)], + ) + participant = MagicMock(spec=rtc.RemoteParticipant) + participant.identity = "user_err" + + ws1 = _ScriptedWs( + [ + _text_ws_msg({"type": "session.created"}), + _text_ws_msg({"type": "error", "error": "boom"}), + ] + ) + ws2 = _ScriptedWs([_text_ws_msg({"type": "session.created"})]) + + mock_session = MagicMock() + mock_session.ws_connect = MagicMock( + side_effect=[_ws_context(ws1), _ws_context(ws2)] + ) + agent._http_session = mock_session + + # First connection: endless frames so the writer keeps sending until + # the reader-initiated close makes a send fail. Second connection: + # empty stream so the pipeline exits cleanly. + empty_stream = AsyncMock() + empty_stream.__aiter__.return_value = iter([]) + empty_stream.aclose = AsyncMock() + + with patch( + "providers.voxtral_realtime.rtc.AudioStream", + side_effect=[_EndlessAudioStream(), empty_stream], + ): + await asyncio.wait_for( + agent._run_transcription_pipeline(participant, MagicMock(), "en"), + timeout=5.0, + ) + + assert ws1.closed, "reader must close the WS after a server error event" + assert mock_session.ws_connect.call_count == 2, ( + "pipeline must reconnect after the reader-initiated close" + ) + + +class TestTeardownFlush: + async def test_cancel_mid_utterance_emits_synthetic_final_and_closing_commit( + self, + ): + """ + Regression for the speak-then-mute loss: when frames stop before Silero + fires END_OF_SPEECH and the task is cancelled (mute / track + unsubscribed), the open segment must still (a) send its closing + commit(final) so the server request is not left dangling, and (b) emit + a FINAL from the delta text already received — otherwise BBB keeps the + interim caption pending forever and the utterance is lost. + """ + agent = _make_agent( + interim_results=True, + vad_events=[_make_vad_event(agents_vad.VADEventType.START_OF_SPEECH)], + ) + participant = MagicMock(spec=rtc.RemoteParticipant) + participant.identity = "user_mute" + + # session.created + one delta; transcription.done never arrives. + ws = _ScriptedWs( + [ + _text_ws_msg({"type": "session.created"}), + _text_ws_msg({"type": "transcription.delta", "delta": "hello"}), + ] + ) + mock_session = MagicMock() + mock_session.ws_connect = MagicMock(return_value=_ws_context(ws)) + agent._http_session = mock_session + + interim = [] + final = [] + agent.on("interim_transcript", lambda **kw: interim.append(kw)) + agent.on("final_transcript", lambda **kw: final.append(kw)) + + with patch( + "providers.voxtral_realtime.rtc.AudioStream", + return_value=_EndlessAudioStream(), + ): + task = asyncio.create_task( + agent._run_transcription_pipeline(participant, MagicMock(), "en") + ) + # Wait until the delta has been received and emitted as interim. + for _ in range(500): + if interim: + break + await asyncio.sleep(0.01) + assert interim, "expected an interim before cancelling" + + task.cancel() + await asyncio.wait_for(task, timeout=5.0) + + # Let the emit task scheduled during teardown run. + for _ in range(5): + await asyncio.sleep(0) + + texts = [kw["event"].alternatives[0].text for kw in final] + assert texts == ["hello"], ( + f"cancellation mid-utterance must emit a best-effort FINAL from the " + f"accumulated delta text, got {texts}" + ) + closers = [m for m in ws.sent if m.get("final") is True] + assert closers, "cancellation with an open segment must send commit(final=True)" + + async def test_end_of_stream_waits_for_late_transcription_done(self): + """ + Regression for the tail-utterance drop at clean stream end: the server's + transcription.done for the flushed segment arrives after the writer has + finished. The reader must be drained, not cancelled immediately, so the + tail utterance still gets its real FINAL. + """ + agent = _make_agent( + interim_results=True, + vad_events=[_make_vad_event(agents_vad.VADEventType.START_OF_SPEECH)], + ) + participant = MagicMock(spec=rtc.RemoteParticipant) + participant.identity = "user_tail" + + ws = _ScriptedWs([_text_ws_msg({"type": "session.created"})]) + + # Release the transcription only after the writer has sent the closing + # commit(final) — mirroring real server causality at end of stream. + real_receive = ws.receive + released = False + + async def _receive(*args, **kwargs): + nonlocal released + if not ws._messages and not ws.closed and not released: + while not any(m.get("final") is True for m in ws.sent): + await asyncio.sleep(0) + released = True + ws._messages = [ + _text_ws_msg({"type": "transcription.delta", "delta": "tail"}), + _text_ws_msg({"type": "transcription.done", "text": "tail words"}), + ] + return await real_receive() + + ws.receive = _receive + + mock_session = MagicMock() + mock_session.ws_connect = MagicMock(return_value=_ws_context(ws)) + agent._http_session = mock_session + + audio_events = [MagicMock(frame=_make_loud_frame()) for _ in range(3)] + mock_stream = AsyncMock() + mock_stream.__aiter__.return_value = iter(audio_events) + mock_stream.aclose = AsyncMock() + + final = [] + agent.on("final_transcript", lambda **kw: final.append(kw)) + + with patch( + "providers.voxtral_realtime.rtc.AudioStream", return_value=mock_stream + ): + await asyncio.wait_for( + agent._run_transcription_pipeline(participant, MagicMock(), "en"), + timeout=5.0, + ) + await asyncio.sleep(0) + + texts = [kw["event"].alternatives[0].text for kw in final] + assert texts == ["tail words"], ( + f"the tail segment's late transcription.done must still be read " + f"before teardown, got {texts}" + ) From 9ea0e10facc2c89d1646ae655dfb9115f91e884e Mon Sep 17 00:00:00 2001 From: timo Date: Tue, 7 Jul 2026 15:04:49 +0200 Subject: [PATCH 05/11] fix(voxtral): drop redundant bare commit at segment close, detect done/segment desync The reader pairs each segment's transcription.done with a queued start time in FIFO order. This is only sound if one open->close cycle produces exactly one done; the close sequence sends a bare commit before commit(final), which the vLLM reference client does not, so an extra server-side done per close would silently shift the pairing and mis-stamp every later segment's BBB transcriptId. Probe test 5 (scripts/probe_protocol.py, run against the production server with real speech) settles this empirically: both close shapes yield exactly one done per segment with identical text, but the bare commit delays the done by ~0.35 s because the server processes an extra commit boundary first. Drop it: same behavior, one third of a second less final-caption latency after every pause. Pairing desync remains a silent failure mode if server behavior ever changes, so make it observable: a transcription event arriving with an empty segment_starts queue now logs a warning instead of quietly using a wall-clock fallback timestamp, and teardown logs segments that never received their done. --- CHANGELOG.md | 1 + providers/voxtral_realtime.py | 48 +++++++++++++++++++++++++---------- tests/test_voxtral_agent.py | 16 +++++------- 3 files changed, 42 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d339226..fa77836 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Final releases will consolidate all intermediate changes in chronological order. * feat(voxtral): replace RMS VAD with Silero neural VAD on Python 3.11 * fix(voxtral): reduce word loss at max-buffer segment boundaries * fix(voxtral): recover from reader failures and flush segments on teardown +* fix(voxtral): drop redundant bare commit at segment close, detect done/segment desync * build: add GitHub Actions workflow for running tests ## v0.2.0 diff --git a/providers/voxtral_realtime.py b/providers/voxtral_realtime.py index 30d5e27..d744a29 100644 --- a/providers/voxtral_realtime.py +++ b/providers/voxtral_realtime.py @@ -243,6 +243,22 @@ async def _vad_loop( seg_text = "" seg_start: float | None = None + def _pop_segment_start(context: str) -> float: + if segment_starts: + return segment_starts.popleft() + # One transcription.done per opener commit is the pairing + # invariant. An empty queue here means the server emitted more + # transcription events than segments were opened, so this and + # every later segment gets a mis-stamped start_time (and thus a + # wrong BBB transcriptId that can overwrite a neighbor's caption). + fallback = time.time() - open_time + logging.warning( + f"Voxtral: {context} with no queued segment start for " + f"{participant.identity} — FIFO pairing desync; using " + f"wall-clock fallback t={fallback:.3f}s" + ) + return fallback + def _emit_transcript(final: bool, text: str, start_time: float) -> None: self.emit( "final_transcript" if final else "interim_transcript", @@ -318,11 +334,7 @@ async def _reader() -> None: if msg_type == "transcription.delta": if seg_start is None: # Pair this segment with the start time its opener pushed. - seg_start = ( - segment_starts.popleft() - if segment_starts - else time.time() - open_time - ) + seg_start = _pop_segment_start("transcription.delta") logging.debug( f"Voxtral: first delta for {participant.identity} " f"at t={time.time() - open_time:.3f}s " @@ -347,11 +359,7 @@ async def _reader() -> None: if seg_start is None: # Zero-delta segment: consume its queued start anyway so # later segments stay paired with their own openers. - seg_start = ( - segment_starts.popleft() - if segment_starts - else time.time() - open_time - ) + seg_start = _pop_segment_start("transcription.done") if seg_text: _emit_transcript(True, seg_text, seg_start) @@ -382,8 +390,8 @@ async def _reader() -> None: # server streams transcription.delta events back as audio arrives # (first delta ~0.6 s after speech start), which _reader emits as # INTERIM. While idle, only a bounded pre-roll is kept locally. - # 3. On speech end (Silero END_OF_SPEECH or max-buffer): send closing - # commit + commit(final). The server emits transcription.done, which + # 3. On speech end (Silero END_OF_SPEECH or max-buffer): send + # commit(final). The server emits transcription.done, which # _reader emits as FINAL. # Each segment is its own streaming request with its own entry in # segment_starts, so consecutive segments — including max-buffer splits @@ -468,12 +476,17 @@ async def _open() -> None: await _flush_pending() async def _close() -> None: - """Flush remaining audio and close the utterance's request.""" + """Flush remaining audio and close the utterance's request. + + Only commit(final) is sent, matching the vLLM reference client. + Probe test 5 confirmed a preceding bare commit is redundant — + same text, one done either way — and it delays the done by + ~0.35 s (the server processes an extra commit boundary first). + """ nonlocal stream_open, pending if pending: await _append(pending) pending = b"" - await ws.send_json({"type": "input_audio_buffer.commit"}) await ws.send_json({"type": "input_audio_buffer.commit", "final": True}) stream_open = False # Pre-roll is intentionally NOT cleared: if this was a max-buffer @@ -571,6 +584,13 @@ async def _drain_reader() -> None: vad_task.cancel() await asyncio.gather(reader_task, vad_task, return_exceptions=True) await vad_stream.aclose() + if segment_starts: + # Expected when teardown interrupts an open segment; anything + # beyond that means dones went missing (see _pop_segment_start). + logging.info( + f"Voxtral: {len(segment_starts)} opened segment(s) without a " + f"transcription.done at teardown for {participant.identity}" + ) if seg_text: # Teardown caught a segment mid-flight: its transcription.done # will never be read, and BBB would show the already-emitted diff --git a/tests/test_voxtral_agent.py b/tests/test_voxtral_agent.py index fc71a37..9b0f248 100644 --- a/tests/test_voxtral_agent.py +++ b/tests/test_voxtral_agent.py @@ -622,13 +622,11 @@ async def _send_json(data): await agent._run_transcription_pipeline(participant, MagicMock(), "en") commits = [m for m in sent if m.get("type") == "input_audio_buffer.commit"] - closers = [m for m in commits if m.get("final") is True] - bare = [m for m in commits if "final" not in m] - # Each open sends one bare commit; each close sends one bare + one final. - openers = len(bare) - len(closers) - assert openers >= 2, ( + # Each open sends one bare commit; each close sends one final commit. + openers = [m for m in commits if "final" not in m] + assert len(openers) >= 2, ( f"expected the stream to reopen after a max-buffer split " - f"(>=2 opener commits), got {openers}" + f"(>=2 opener commits), got {len(openers)}" ) async def test_max_buffer_split_segments_get_distinct_start_times( @@ -687,7 +685,7 @@ def _closers(): # Deliver each segment's transcription only after the writer has sent # the corresponding commits — mirroring the real server's causality. # Segment 1 events after its close; segment 2 events after reopen - # (open1 + close1 + open2 = 3 bare commits). + # (bare commits are openers only: open1 + open2 = 2 bare commits). closed_msg = MagicMock() closed_msg.type = aiohttp.WSMsgType.CLOSED script = [ @@ -701,11 +699,11 @@ def _closers(): _text_ws_msg({"type": "transcription.done", "text": "hello"}), ), ( - lambda: len(_bare_commits()) >= 3, + lambda: len(_bare_commits()) >= 2, _text_ws_msg({"type": "transcription.delta", "delta": "world"}), ), ( - lambda: len(_bare_commits()) >= 3, + lambda: len(_bare_commits()) >= 2, _text_ws_msg({"type": "transcription.done", "text": "world"}), ), (lambda: True, closed_msg), From 5ceb949ba8576e14d3e45998b2cd920d46db982b Mon Sep 17 00:00:00 2001 From: timo Date: Tue, 7 Jul 2026 15:20:14 +0200 Subject: [PATCH 06/11] fix(voxtral): replay a longer overlap when reopening after a max-buffer split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A max-buffer split closes a streaming request mid-speech and reopens on the next frame. The reopened request starts mid-utterance with no context, and the replayed lead-in is the same rolling pre-roll used for fresh onsets: capped at min(pre-roll, min-silence) = 0.5 s so a normal onset cannot replay the previous utterance's tail across a silence gap. That cap conflates two different situations. The Voxtral Realtime paper recommends ~1.28 s of left-padding at stream start ("similar to attention sinks"), so 0.5 s of context is why words right after a split keep coming out wrong or missing. Size the rolling buffer for a split overlap (VOXTRAL_SPLIT_OVERLAP_S, default 1.5 s) and choose the replay length at open time: a reopen after a cap-triggered close replays the full overlap — mid-speech the buffer holds only the current utterance, so the longer replay is safe — while a fresh onset keeps the min-silence-capped pre-roll. An END_OF_SPEECH observed while no request is open resets the split flag, so an utterance that ends in the one-frame window after a cap close does not leak the long replay into the next onset. Segment starts are backdated by the replayed length, keeping transcriptId timestamps consistent. The overlap is transcribed twice; duplicated words at split boundaries are the accepted trade-off for not losing them. The alternative — no overlap plus client-side stitching of cut words — cannot work, as the model has no phonetic context to transcribe a word fragment on either side of the boundary (see notes/progressive-transcription-investigation.md). Fix, in passing, the buffer trim form: `del preroll[:-preroll_max]` deletes nothing when the cap is zero, growing the buffer without bound. Regression tests verified to fail against the previous implementation: split reopens must replay the byte-exact tail of the prior segment, and fresh onsets must stay onset-capped even with a full overlap buffer. --- .env.example | 6 ++ CHANGELOG.md | 1 + providers/voxtral_realtime.py | 83 ++++++++++++----- tests/test_voxtral_agent.py | 166 ++++++++++++++++++++++++++++++++++ 4 files changed, 231 insertions(+), 25 deletions(-) diff --git a/.env.example b/.env.example index 92ab4ae..393c28b 100644 --- a/.env.example +++ b/.env.example @@ -95,6 +95,12 @@ GLADIA_TRANSLATION_LANG_MAP="de:de-DE,en:en-US,es:es-ES,fr:fr-FR,hi:hi-IN,it:it- # word onset preceding Silero's detection is not lost (default: 0.5) #VOXTRAL_VAD_PREROLL_S=0.5 +# Overlap replayed when a max-buffer split reopens mid-speech (default: 1.5). +# The reopened request starts mid-utterance with no context; ~1.28 s of lead-in +# is what the Voxtral paper recommends. The overlap is transcribed twice, so +# some duplicated words at split boundaries are the trade-off for not losing them. +#VOXTRAL_SPLIT_OVERLAP_S=1.5 + # Safety cap on a single streaming request before a forced split (default: 30.0). # Only reached during uninterrupted monologue; each split risks clipping a word, # so keep it high. Lower it to commit final transcripts sooner during long diff --git a/CHANGELOG.md b/CHANGELOG.md index fa77836..763b45c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Final releases will consolidate all intermediate changes in chronological order. * fix(voxtral): reduce word loss at max-buffer segment boundaries * fix(voxtral): recover from reader failures and flush segments on teardown * fix(voxtral): drop redundant bare commit at segment close, detect done/segment desync +* fix(voxtral): replay a longer overlap when reopening after a max-buffer split * build: add GitHub Actions workflow for running tests ## v0.2.0 diff --git a/providers/voxtral_realtime.py b/providers/voxtral_realtime.py index d744a29..21b1f87 100644 --- a/providers/voxtral_realtime.py +++ b/providers/voxtral_realtime.py @@ -38,6 +38,12 @@ # Rolling pre-roll kept while idle so the word onset preceding Silero's # START_OF_SPEECH (its prefix padding) is sent once a streaming request opens. _VAD_PREROLL_S = float(os.getenv("VOXTRAL_VAD_PREROLL_S", "0.5")) +# Audio replayed when a max-buffer split reopens mid-speech. The reopened +# request starts mid-utterance with no context; the Voxtral paper recommends +# ~1.28 s of lead-in (16 frames, "similar to attention sinks"), so short +# overlap loses or garbles the words right after a split. The overlap is +# transcribed twice — some duplication at split boundaries is the trade-off. +_SPLIT_OVERLAP_S = float(os.getenv("VOXTRAL_SPLIT_OVERLAP_S", "1.5")) # Reconnect backoff bounds for a dropped WebSocket connection. _RETRY_DELAY_INITIAL_S = 1.0 _RETRY_DELAY_MAX_S = 30.0 @@ -435,12 +441,23 @@ async def _writer() -> None: # owned exclusively by _vad_task. Keeping them separate is what lets a # max-buffer split mid-utterance reopen immediately on the next frame # (we never clobber the VAD's view of whether speech is ongoing). - # Pre-roll must not exceed the VAD's min-silence: otherwise a normal - # onset could replay the previous utterance's tail (it would not have - # rolled out of the buffer during the gap) and duplicate it. - preroll_secs_max = min(_VAD_PREROLL_S, _VAD_MIN_SILENCE_S) - preroll_max = int(preroll_secs_max * _TARGET_SAMPLE_RATE) * 2 # int16 bytes + # + # One rolling buffer of recent audio, two replay lengths at _open(): + # - Fresh onset: at most the VAD pre-roll, capped by min-silence — + # otherwise a normal onset could replay the previous utterance's + # tail (it would not have rolled out of the buffer during the + # gap) and duplicate it. + # - Max-buffer split reopen: the full split overlap. Mid-speech the + # buffer holds only the current utterance, so the longer replay + # is safe and gives the reopened request the left-context the + # model needs (see _SPLIT_OVERLAP_S). + onset_max = ( + int(min(_VAD_PREROLL_S, _VAD_MIN_SILENCE_S) * _TARGET_SAMPLE_RATE) * 2 + ) + split_max = int(_SPLIT_OVERLAP_S * _TARGET_SAMPLE_RATE) * 2 # int16 bytes + preroll_max = max(onset_max, split_max) preroll = bytearray() # rolling window of the most recent audio + split_reopen = False # next _open() continues a cap-split utterance pending = b"" # audio buffered for chunked append while open open_secs = 0.0 # duration of the current open segment stream_open = False @@ -461,18 +478,23 @@ async def _flush_pending() -> None: async def _open() -> None: """Open a streaming request and replay the captured lead-in.""" - nonlocal stream_open, pending, open_secs + nonlocal stream_open, pending, open_secs, split_reopen commit_event.clear() # discard any stale END from a prior segment - # Record this segment's start (backdated by the replayed pre-roll) + limit = split_max if split_reopen else onset_max + split_reopen = False + replay = ( + bytes(preroll[max(0, len(preroll) - limit) :]) if limit > 0 else b"" + ) + preroll.clear() + # Record this segment's start (backdated by the replayed lead-in) # for the reader to pair with the segment's transcription events. - preroll_secs = len(preroll) / (_TARGET_SAMPLE_RATE * 2) - segment_starts.append(time.time() - open_time - preroll_secs) + replay_secs = len(replay) / (_TARGET_SAMPLE_RATE * 2) + segment_starts.append(time.time() - open_time - replay_secs) await ws.send_json({"type": "input_audio_buffer.commit"}) stream_open = True open_secs = 0.0 - if preroll: - pending += bytes(preroll) - preroll.clear() + if replay: + pending += replay await _flush_pending() async def _close() -> None: @@ -489,10 +511,10 @@ async def _close() -> None: pending = b"" await ws.send_json({"type": "input_audio_buffer.commit", "final": True}) stream_open = False - # Pre-roll is intentionally NOT cleared: if this was a max-buffer - # split, the next frame reopens immediately and replays the last - # ~0.5 s as overlap so the boundary word is not lost. _open() - # clears it after replaying. + # The rolling buffer is intentionally NOT cleared: if this was a + # max-buffer split, the next frame reopens immediately and + # replays the split overlap so the boundary words carry fully + # into the next segment. _open() clears it after replaying. try: async for audio_event in audio_stream: @@ -508,6 +530,14 @@ async def _close() -> None: resampled = _to_pcm16_16k(frame) + # An END arriving while no request is open means the + # cap-split utterance finished before the reopen (or a + # prior segment's END is stale): whatever opens next is a + # fresh onset, not a split continuation. + if not stream_open and commit_event.is_set(): + commit_event.clear() + split_reopen = False + # Open a request as soon as the VAD reports speech. if is_in_speech and not stream_open: await _open() @@ -517,17 +547,16 @@ async def _close() -> None: open_secs += frame_duration await _flush_pending() - # Keep a bounded rolling pre-roll of the most recent audio, - # whether idle or open. _open() replays it as the segment's - # lead-in: on a fresh onset that is the audio before the VAD - # fired; on a max-buffer split it is the overlap that carries - # the boundary word fully into the next segment instead of - # cutting it in half. Inter-utterance silence (>= Silero's - # min_silence, which exceeds the pre-roll length) flushes stale - # speech, so a normal onset never replays a prior utterance. + # Keep a bounded rolling buffer of the most recent audio, + # whether idle or open. _open() replays its tail as the + # segment's lead-in: on a fresh onset the onset-capped + # pre-roll (the audio before the VAD fired); on a + # max-buffer split the full overlap that carries the + # boundary words into the next segment instead of cutting + # them in half. preroll += resampled if len(preroll) > preroll_max: - del preroll[:-preroll_max] + del preroll[: len(preroll) - preroll_max] # Close on end-of-speech (Silero) or the max-buffer safety cap. # is_in_speech is deliberately left untouched: if the speaker is @@ -535,6 +564,10 @@ async def _close() -> None: if stream_open and ( commit_event.is_set() or open_secs >= _MAX_BUFFER_DURATION_S ): + # A cap-triggered close (no END) splits mid-speech; the + # reopen replays the full overlap instead of the onset + # pre-roll. + split_reopen = not commit_event.is_set() commit_event.clear() await _close() except asyncio.CancelledError: diff --git a/tests/test_voxtral_agent.py b/tests/test_voxtral_agent.py index 9b0f248..d0e0d76 100644 --- a/tests/test_voxtral_agent.py +++ b/tests/test_voxtral_agent.py @@ -1,4 +1,5 @@ import asyncio +import base64 import json from unittest.mock import AsyncMock, MagicMock, patch @@ -755,6 +756,171 @@ async def _receive(): ) +# ── Split overlap and onset pre-roll ─────────────────────────────────────────── + + +def _segments_from_sent(sent: list[dict]) -> list[bytes]: + """Group appended audio bytes into segments delimited by opener commits.""" + segments: list[bytearray] = [] + for m in sent: + t = m.get("type") + if t == "input_audio_buffer.commit" and "final" not in m: + segments.append(bytearray()) + elif t == "input_audio_buffer.append" and segments: + segments[-1] += base64.b64decode(m["audio"]) + return [bytes(s) for s in segments] + + +class _DeferredStartVadStream: + """VAD stream double that fires START_OF_SPEECH after N pushed frames.""" + + def __init__(self, after_frames: int): + self._after = after_frames + self.pushed = 0 + self._fired = False + + def push_frame(self, _frame): + self.pushed += 1 + + def end_input(self): + pass + + async def aclose(self): + pass + + def __aiter__(self): + return self + + async def __anext__(self): + if self._fired: + await asyncio.Event().wait() # block until cancelled + while self.pushed < self._after: + await asyncio.sleep(0) + self._fired = True + return _make_vad_event(agents_vad.VADEventType.START_OF_SPEECH) + + +class TestSplitOverlap: + def _run_pipeline(self, agent, frames): + participant = MagicMock(spec=rtc.RemoteParticipant) + participant.identity = "user_overlap" + + sent: list[dict] = [] + + async def _send_json(data): + sent.append(data) + await asyncio.sleep(0) + + mock_ws = AsyncMock() + mock_ws.receive = AsyncMock( + side_effect=[_text_ws_msg({"type": "session.created"})] + ) + mock_ws.send_json = _send_json + cm = AsyncMock() + cm.__aenter__ = AsyncMock(return_value=mock_ws) + cm.__aexit__ = AsyncMock(return_value=False) + mock_session = MagicMock() + mock_session.ws_connect = MagicMock(return_value=cm) + agent._http_session = mock_session + + audio_events = [MagicMock(frame=f) for f in frames] + mock_stream = AsyncMock() + mock_stream.__aiter__.return_value = iter(audio_events) + mock_stream.aclose = AsyncMock() + + return participant, mock_stream, sent + + async def test_split_reopen_replays_overlap(self, monkeypatch): + """ + Regression for word loss at max-buffer splits: the reopened segment + must begin with the overlap — the tail of the audio already sent in + the previous segment — so boundary words carry fully into the new + request instead of being cut at the commit boundary. + """ + import providers.voxtral_realtime as vr + + monkeypatch.setattr(vr, "_MAX_BUFFER_DURATION_S", 0.015) + # Shrink the onset pre-roll far below one frame so a full-frame overlap + # can ONLY come from the split path: if the reopen were mistaken for a + # fresh onset, it would replay a sliver and the assertion would fail. + monkeypatch.setattr(vr, "_VAD_PREROLL_S", 0.005) + + agent = _make_agent( + # START only, no END → continuous speech across the split. + vad_events=[_make_vad_event(agents_vad.VADEventType.START_OF_SPEECH)], + ) + # Distinct per-frame amplitudes so overlapping byte ranges are + # distinguishable (constant amplitude would make any comparison pass). + frames = [_make_audio_frame(amplitude=100 + i) for i in range(6)] + participant, mock_stream, sent = self._run_pipeline(agent, frames) + + with patch( + "providers.voxtral_realtime.rtc.AudioStream", return_value=mock_stream + ): + await agent._run_transcription_pipeline(participant, MagicMock(), "en") + + segments = _segments_from_sent(sent) + assert len(segments) >= 2, f"expected a split, got {len(segments)} segment(s)" + seg1, seg2 = segments[0], segments[1] + + frame_bytes = 320 # 160 samples of int16 + overlap = min(len(seg1), len(seg2)) + assert overlap >= frame_bytes and seg1.endswith(seg2[:overlap]), ( + "the reopened segment must start with the tail of the previous " + "segment's audio (the split overlap)" + ) + + async def test_fresh_onset_replay_is_capped(self, monkeypatch): + """ + The rolling buffer is now sized for the split overlap (long), but a + FRESH onset must still replay only the onset pre-roll — otherwise it + would replay the previous utterance's tail from before the silence + gap and duplicate it. + """ + import providers.voxtral_realtime as vr + + onset_secs = 0.02 # 2 frames + monkeypatch.setattr(vr, "_VAD_PREROLL_S", onset_secs) + monkeypatch.setattr(vr, "_SPLIT_OVERLAP_S", 10.0) # buffer far larger + + idle_frames = 100 # 1 s of audio buffered before speech starts + vad_stream = _DeferredStartVadStream(after_frames=idle_frames) + mock_vad = MagicMock() + mock_vad.stream.return_value = vad_stream + agent = VoxtralRealtimeSttAgent(_make_config(), vad=mock_vad) + + frames = [_make_audio_frame(amplitude=100 + i) for i in range(idle_frames + 4)] + participant, mock_stream, sent = self._run_pipeline(agent, frames) + + with patch( + "providers.voxtral_realtime.rtc.AudioStream", return_value=mock_stream + ): + await asyncio.wait_for( + agent._run_transcription_pipeline(participant, MagicMock(), "en"), + timeout=5.0, + ) + + segments = _segments_from_sent(sent) + assert len(segments) == 1, f"expected one segment, got {len(segments)}" + seg = segments[0] + + frame_bytes = 320 + onset_bytes = int(onset_secs * 16000) * 2 + full_audio = b"".join(f.data for f in frames) + + # The segment must be a contiguous suffix of the input audio (replay + # directly precedes the live frames, no gap) … + assert full_audio.endswith(seg), "segment audio must be a contiguous suffix" + # … and bounded: onset replay + the few frames after the VAD fired — + # NOT the ~32 kB of idle audio sitting in the oversized buffer. + max_live_frames = 6 + assert len(seg) <= onset_bytes + max_live_frames * frame_bytes, ( + f"fresh onset replayed {len(seg)} bytes; the onset cap is " + f"{onset_bytes} bytes — the long split-overlap buffer must not be " + f"replayed on a fresh onset" + ) + + # ── Failure recovery and teardown flush ──────────────────────────────────────── From b80a36f3fd5c95229f9494c63b083810777342ed Mon Sep 17 00:00:00 2001 From: timo Date: Tue, 7 Jul 2026 15:29:02 +0200 Subject: [PATCH 07/11] fix(voxtral): keep replacement pipeline tracked across locale-change restarts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A locale change restarts transcription via stop→start: stop_transcription_for_user cancels the pipeline task and start_transcription_for_user synchronously registers the replacement under the same identity. task.cancel() only schedules the cancellation, so the old task's finally block runs after the replacement is registered — and its unconditional processing_info.pop() removes the replacement's entry. The new pipeline keeps running but is untracked: it can no longer be stopped, and a later start for the same participant spawns a second concurrent pipeline on the same track, producing duplicate transcripts. Guard the pop: deregister only when the stored task is asyncio.current_task(), i.e. when this pipeline still owns the entry. A pipeline that was replaced leaves the replacement's registration alone; a pipeline that ends normally still cleans up after itself. The same stop→start-over-unconditional-pop pattern exists in the OpenAI provider and the base class, but those are maintained separately and are deliberately left untouched on this branch; the equivalent guard should be applied there upstream. The regression test drives a real pipeline through a locale change and was verified to fail against the unguarded implementation. --- CHANGELOG.md | 1 + providers/voxtral_realtime.py | 10 +++++++- tests/test_voxtral_agent.py | 47 +++++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 763b45c..8458661 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Final releases will consolidate all intermediate changes in chronological order. * fix(voxtral): recover from reader failures and flush segments on teardown * fix(voxtral): drop redundant bare commit at segment close, detect done/segment desync * fix(voxtral): replay a longer overlap when reopening after a max-buffer split +* fix(voxtral): keep replacement pipeline tracked across locale-change restarts * build: add GitHub Actions workflow for running tests ## v0.2.0 diff --git a/providers/voxtral_realtime.py b/providers/voxtral_realtime.py index 21b1f87..7a9264c 100644 --- a/providers/voxtral_realtime.py +++ b/providers/voxtral_realtime.py @@ -218,7 +218,15 @@ async def _run_transcription_pipeline( f"Voxtral Realtime transcription for {participant.identity} cancelled." ) finally: - self.processing_info.pop(participant.identity, None) + # Deregister only if this task still owns the entry: a locale + # change (stop→start) registers the replacement task before this + # cancelled task runs its finally block, so an unconditional pop + # would deregister the replacement — leaving it running but + # untracked (unstoppable, and a later start would spawn a + # duplicate pipeline on the same track). + info = self.processing_info.get(participant.identity) + if info and info.get("task") is asyncio.current_task(): + self.processing_info.pop(participant.identity, None) async def _vad_loop( self, diff --git a/tests/test_voxtral_agent.py b/tests/test_voxtral_agent.py index d0e0d76..3928a8d 100644 --- a/tests/test_voxtral_agent.py +++ b/tests/test_voxtral_agent.py @@ -1032,6 +1032,53 @@ async def test_server_error_event_closes_ws_and_reconnects(self, monkeypatch): ) +class TestLocaleUpdateRace: + async def test_locale_change_does_not_orphan_replacement_pipeline(self): + """ + Regression for the stop→start restart race: _update_stream_locale + cancels the old pipeline task and synchronously registers a new one + under the same identity. task.cancel() only schedules the + cancellation, so the old task's cleanup runs AFTER the new entry + exists — an unconditional pop there deregisters the replacement, + leaving it running but untracked (unstoppable, and a later start + would spawn a duplicate pipeline on the same track). + """ + participant = _make_participant("user_1") + agent = _make_agent_with_room(participants={"p1": participant}) + + ws1 = _ScriptedWs([_text_ws_msg({"type": "session.created"})]) + ws2 = _ScriptedWs([_text_ws_msg({"type": "session.created"})]) + mock_session = MagicMock() + mock_session.ws_connect = MagicMock( + side_effect=[_ws_context(ws1), _ws_context(ws2)] + ) + agent._http_session = mock_session + + with patch( + "providers.voxtral_realtime.rtc.AudioStream", + side_effect=[_EndlessAudioStream(), _EndlessAudioStream()], + ): + agent.start_transcription_for_user("user_1", "en-US", "voxtral-realtime") + task1 = agent.processing_info["user_1"]["task"] + await asyncio.sleep(0.05) # let pipeline 1 get going + + agent._update_stream_locale("user_1", "de-DE") + task2 = agent.processing_info["user_1"]["task"] + assert task2 is not task1 + + # Let the cancelled task run its cleanup to completion. + await asyncio.wait_for(task1, timeout=5.0) + await asyncio.sleep(0) + + assert agent.processing_info.get("user_1", {}).get("task") is task2, ( + "the cancelled pipeline's cleanup must not deregister its replacement" + ) + + agent.stop_transcription_for_user("user_1") + await asyncio.wait_for(task2, timeout=5.0) + assert "user_1" not in agent.processing_info + + class TestTeardownFlush: async def test_cancel_mid_utterance_emits_synthetic_final_and_closing_commit( self, From 518d0498da926c12ed05e75d255f1603058ba4f1 Mon Sep 17 00:00:00 2001 From: timo Date: Tue, 7 Jul 2026 15:33:01 +0200 Subject: [PATCH 08/11] fix(voxtral): resample with rtc.AudioResampler to stop aliasing into the speech band MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audio arrives from LiveKit at 48 kHz and the model requires 16 kHz. The conversion uses np.interp — linear interpolation with no low-pass filter — so all energy above the 16 kHz Nyquist (8 kHz) folds back into the speech band as aliasing distortion on every frame. That is a constant, diffuse transcription-accuracy penalty that no amount of protocol tuning can recover. Replace it with rtc.AudioResampler (SoX), which band-limits before decimating. The resampler is streaming — its filter state must persist across frames, as resampling each 10 ms frame independently would reintroduce boundary artifacts — so the per-frame pure function becomes a per-stream _AudioNormalizer instance, drained via flush() at end of stream before the final commit. Mono downmix and the 16 kHz passthrough short-circuit are unchanged. The new anti-aliasing regression test feeds a 10 kHz tone at 48 kHz through the normalizer and requires >20 dB attenuation; the previous implementation fails it, passing the tone through at nearly full energy aliased to 6 kHz. --- CHANGELOG.md | 1 + providers/voxtral_realtime.py | 50 ++++++++++++++++-------- tests/test_voxtral_agent.py | 73 ++++++++++++++++++++++++----------- 3 files changed, 86 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8458661..e5570ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Final releases will consolidate all intermediate changes in chronological order. * fix(voxtral): drop redundant bare commit at segment close, detect done/segment desync * fix(voxtral): replay a longer overlap when reopening after a max-buffer split * fix(voxtral): keep replacement pipeline tracked across locale-change restarts +* fix(voxtral): resample with rtc.AudioResampler to stop aliasing into the speech band * build: add GitHub Actions workflow for running tests ## v0.2.0 diff --git a/providers/voxtral_realtime.py b/providers/voxtral_realtime.py index 7a9264c..2ce2357 100644 --- a/providers/voxtral_realtime.py +++ b/providers/voxtral_realtime.py @@ -469,6 +469,7 @@ async def _writer() -> None: pending = b"" # audio buffered for chunked append while open open_secs = 0.0 # duration of the current open segment stream_open = False + normalizer = _AudioNormalizer() # stateful: one per audio stream async def _append(data: bytes) -> None: await ws.send_json( @@ -536,7 +537,7 @@ async def _close() -> None: vad_stream.push_frame(frame) await asyncio.sleep(0) - resampled = _to_pcm16_16k(frame) + resampled = normalizer.process(frame) # An END arriving while no request is open means the # cap-split utterance finished before the reopen (or a @@ -595,8 +596,9 @@ async def _close() -> None: ) raise - # End of stream: flush any open request. + # End of stream: drain the resampler tail and flush any open request. if stream_open: + pending += normalizer.flush() await _close() vad_stream.end_input() @@ -647,20 +649,36 @@ async def _drain_reader() -> None: ) -def _to_pcm16_16k(frame: rtc.AudioFrame) -> bytes: - """Resample an AudioFrame to 16 kHz mono PCM16.""" - samples = np.frombuffer(frame.data, dtype=np.int16).astype(np.float32) +class _AudioNormalizer: + """Downmix to mono and resample to _TARGET_SAMPLE_RATE PCM16. - if frame.num_channels > 1: - samples = samples.reshape(-1, frame.num_channels).mean(axis=1) + Wraps rtc.AudioResampler (SoX), which low-pass filters before decimating — + naive linear interpolation aliases everything above the target Nyquist + (8 kHz) into the speech band and measurably hurts transcription accuracy. + The resampler is streaming: its filter state must persist across frames, + so use one instance per audio stream, and call flush() at end of stream + to drain the tail samples held back by the filter. + """ - if frame.sample_rate != _TARGET_SAMPLE_RATE: - n_orig = len(samples) - n_target = int(round(n_orig * _TARGET_SAMPLE_RATE / frame.sample_rate)) - samples = np.interp( - np.linspace(0, n_orig - 1, n_target), - np.arange(n_orig), - samples, - ) + def __init__(self): + self._resampler: rtc.AudioResampler | None = None + self._input_rate: int | None = None - return np.clip(samples, -32768, 32767).astype(np.int16).tobytes() + def process(self, frame: rtc.AudioFrame) -> bytes: + samples = np.frombuffer(frame.data, dtype=np.int16) + if frame.num_channels > 1: + samples = ( + samples.reshape(-1, frame.num_channels).mean(axis=1).astype(np.int16) + ) + if frame.sample_rate == _TARGET_SAMPLE_RATE: + return samples.tobytes() + if self._input_rate != frame.sample_rate: + self._resampler = rtc.AudioResampler(frame.sample_rate, _TARGET_SAMPLE_RATE) + self._input_rate = frame.sample_rate + frames = self._resampler.push(bytearray(samples.tobytes())) + return b"".join(bytes(f.data) for f in frames) + + def flush(self) -> bytes: + if self._resampler is None: + return b"" + return b"".join(bytes(f.data) for f in self._resampler.flush()) diff --git a/tests/test_voxtral_agent.py b/tests/test_voxtral_agent.py index 3928a8d..c701c69 100644 --- a/tests/test_voxtral_agent.py +++ b/tests/test_voxtral_agent.py @@ -14,7 +14,7 @@ from providers.voxtral_realtime import ( VoxtralRealtimeConfig, VoxtralRealtimeSttAgent, - _to_pcm16_16k, + _AudioNormalizer, ) @@ -177,14 +177,14 @@ def test_custom_host_is_preserved(self): # ── PCM conversion ───────────────────────────────────────────────────────────── -class TestToPcm16_16k: +class TestAudioNormalizer: def test_returns_bytes(self): frame = _make_audio_frame() - assert isinstance(_to_pcm16_16k(frame), bytes) + assert isinstance(_AudioNormalizer().process(frame), bytes) def test_mono_16k_passthrough_preserves_values(self): frame = _make_audio_frame(amplitude=1000, sample_rate=16000, num_channels=1) - result = _to_pcm16_16k(frame) + result = _AudioNormalizer().process(frame) samples = np.frombuffer(result, dtype=np.int16) assert len(samples) == frame.samples_per_channel assert all(s == 1000 for s in samples) @@ -192,28 +192,57 @@ def test_mono_16k_passthrough_preserves_values(self): def test_stereo_downmix_to_mono(self): """Stereo frame with equal channels averages to same amplitude.""" frame = _make_audio_frame(amplitude=1000, sample_rate=16000, num_channels=2) - result = _to_pcm16_16k(frame) + result = _AudioNormalizer().process(frame) samples = np.frombuffer(result, dtype=np.int16) assert len(samples) == frame.samples_per_channel assert all(s == 1000 for s in samples) - def test_resampling_from_48k_produces_correct_length(self): - frame = _make_audio_frame(amplitude=500, sample_rate=48000, num_channels=1) - result = _to_pcm16_16k(frame) - samples = np.frombuffer(result, dtype=np.int16) - expected = round(frame.samples_per_channel * 16000 / 48000) - assert len(samples) == expected - - def test_values_are_clipped_to_int16_range(self): - """numpy clip must keep all output values within ±32767.""" - frame = _make_audio_frame(amplitude=0, sample_rate=16000, num_channels=1) - # Override with float32 extremes stored as int16 (will saturate on cast) - raw = np.array([32767, -32768, 0], dtype=np.float32) - frame.data = raw.astype(np.int16).tobytes() - frame.samples_per_channel = 3 - result = _to_pcm16_16k(frame) - out = np.frombuffer(result, dtype=np.int16) - assert all(-32768 <= s <= 32767 for s in out) + def test_resampling_from_48k_produces_correct_total_length(self): + """process() over many frames + flush() yields ~1/3 the samples. + + The resampler is streaming, so individual frames may return fewer + samples (filter latency); only the drained total is deterministic. + """ + normalizer = _AudioNormalizer() + n_frames = 20 # 200 ms at 48 kHz + out = b"".join( + normalizer.process(_make_audio_frame(amplitude=500, sample_rate=48000)) + for _ in range(n_frames) + ) + out += normalizer.flush() + total_in = n_frames * 160 + expected = total_in * 16000 // 48000 + got = len(out) // 2 + assert abs(got - expected) <= 32, f"expected ~{expected} samples, got {got}" + + def test_downsampling_attenuates_above_target_nyquist(self): + """Anti-aliasing regression: a 10 kHz tone at 48 kHz lies above the + 16 kHz target's Nyquist (8 kHz) and must be strongly attenuated. + Naive linear interpolation instead folds it into the speech band at + nearly full energy — the defect this normalizer replaces. + """ + normalizer = _AudioNormalizer() + rate_in, tone_hz, duration_s = 48000, 10000, 0.2 + t = np.arange(int(rate_in * duration_s)) / rate_in + tone = (0.5 * 32767 * np.sin(2 * np.pi * tone_hz * t)).astype(np.int16) + + out = bytearray() + frame_samples = 480 # 10 ms frames, as LiveKit delivers + for i in range(0, len(tone), frame_samples): + chunk = tone[i : i + frame_samples] + frame = _make_audio_frame(sample_rate=rate_in) + frame.data = chunk.tobytes() + frame.samples_per_channel = len(chunk) + out += normalizer.process(frame) + out += normalizer.flush() + + in_rms = np.sqrt(np.mean(tone.astype(np.float64) ** 2)) + out_samples = np.frombuffer(bytes(out), dtype=np.int16).astype(np.float64) + out_rms = np.sqrt(np.mean(out_samples**2)) if len(out_samples) else 0.0 + assert out_rms < 0.1 * in_rms, ( + f"10 kHz tone must be attenuated by the anti-aliasing filter " + f"(in_rms={in_rms:.0f}, out_rms={out_rms:.0f})" + ) # ── start_transcription_for_user ─────────────────────────────────────────────── From 2eee4f4b34e1be3a373327ed1d5a92d51fd7ebdf Mon Sep 17 00:00:00 2001 From: timo Date: Tue, 7 Jul 2026 15:38:51 +0200 Subject: [PATCH 09/11] fix(voxtral): retry on handshake timeout, require VOXTRAL_BASE_URL at startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two startup/connection hardening changes plus configuration and documentation cleanup: A handshake timeout waiting for session.created is not a reconnectable error: it falls into the generic exception handler and permanently ends transcription for the participant. vLLM spends 2-5 minutes on CUDA-graph warmup after startup, during which the WebSocket may connect but respond slowly — a participant joining in that window loses captions for the whole meeting. Treat TimeoutError like a connection error and retry with the existing backoff. The WebSocket URL falls back to https://api.openai.com/v1 when VOXTRAL_BASE_URL is unset, but OpenAI does not host Voxtral — the fallback can only produce confusing auth/protocol failures at runtime. Require base_url at agent creation and fail with an actionable message. Remove the VOXTRAL_TARGET_SAMPLE_RATE variable: the model requires 16 kHz input unconditionally, so configurability is purely a footgun. The model card mandates temperature 0.0, but vLLM's session.update shape for temperature is undocumented; sending it blind risks an error event per connection. Add probe test 6 to determine empirically which shape (if any) the server accepts before wiring it into the provider. Update README: Python 3.11 requirement (raised when Silero VAD was introduced), Voxtral Realtime in the supported engines, and a provider configuration section. Fix the module docstring's stale claim that a commit is needed to trigger generation. --- .env.example | 9 ++--- CHANGELOG.md | 2 ++ README.md | 28 ++++++++++++++-- providers/voxtral_realtime.py | 33 ++++++++++++++---- tests/test_voxtral_agent.py | 63 +++++++++++++++++++++++++++++++---- 5 files changed, 114 insertions(+), 21 deletions(-) diff --git a/.env.example b/.env.example index 393c28b..cb261f6 100644 --- a/.env.example +++ b/.env.example @@ -102,13 +102,10 @@ GLADIA_TRANSLATION_LANG_MAP="de:de-DE,en:en-US,es:es-ES,fr:fr-FR,hi:hi-IN,it:it- #VOXTRAL_SPLIT_OVERLAP_S=1.5 # Safety cap on a single streaming request before a forced split (default: 30.0). -# Only reached during uninterrupted monologue; each split risks clipping a word, -# so keep it high. Lower it to commit final transcripts sooner during long -# continuous speech (live interim captions are unaffected). +# Only reached during uninterrupted monologue. Splits replay VOXTRAL_SPLIT_OVERLAP_S +# of audio, so lowering this mainly trades duplicated boundary words for faster +# final transcripts during continuous speech (live interim captions are unaffected). #VOXTRAL_MAX_BUFFER_DURATION_S=30.0 -# Target sample rate required by the model in Hz (default: 16000) -#VOXTRAL_TARGET_SAMPLE_RATE=16000 - # Emit incremental transcription.delta events as interim captions (default: true) #VOXTRAL_INTERIM_RESULTS=true diff --git a/CHANGELOG.md b/CHANGELOG.md index e5570ea..410f012 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ Final releases will consolidate all intermediate changes in chronological order. * fix(voxtral): replay a longer overlap when reopening after a max-buffer split * fix(voxtral): keep replacement pipeline tracked across locale-change restarts * fix(voxtral): resample with rtc.AudioResampler to stop aliasing into the speech band +* fix(voxtral): retry on handshake timeout, require VOXTRAL_BASE_URL at startup +* docs: document the Voxtral Realtime provider and Python 3.11 requirement in README * build: add GitHub Actions workflow for running tests ## v0.2.0 diff --git a/README.md b/README.md index 30a4bad..07ba8ed 100644 --- a/README.md +++ b/README.md @@ -7,14 +7,15 @@ Supported STT engines: - **Gladia** — via the official [LiveKit Gladia plugin](https://docs.livekit.io/agents/integrations/stt/gladia/) (default) - **OpenAI** — via the [LiveKit OpenAI plugin](https://docs.livekit.io/agents/models/stt/openai/); supports the official OpenAI API and any OpenAI-compatible endpoint +- **Voxtral Realtime** — [Mistral Voxtral Mini Realtime](https://huggingface.co/mistralai/Voxtral-Mini-4B-Realtime-2602) served by a self-hosted [vLLM](https://docs.vllm.ai/) instance via its realtime WebSocket API ## Getting Started ### Environment prerequisites -- Python 3.10+ +- Python 3.11+ - A LiveKit instance -- A Gladia API key **or** an OpenAI API key (depending on your chosen STT provider) +- A Gladia API key, an OpenAI API key, **or** a vLLM server hosting Voxtral (depending on your chosen STT provider) - uv: - See installation instructions: https://docs.astral.sh/uv/getting-started/installation/ @@ -126,6 +127,29 @@ OPENAI_STT_MODEL=your-model-name > **Note**: OpenAI STT does not support real-time translation. Only the original > transcript language is returned, matching the user's BBB speech locale. +### Voxtral Realtime STT provider + +Set `STT_PROVIDER=voxtral-realtime` to use a self-hosted +[Voxtral Mini Realtime](https://huggingface.co/mistralai/Voxtral-Mini-4B-Realtime-2602) +model served by [vLLM](https://docs.vllm.ai/). The agent streams each +participant's audio over vLLM's realtime WebSocket API, gated by a local +Silero VAD, and emits live interim captions from the model's incremental +deltas. + +```bash +STT_PROVIDER=voxtral-realtime +VOXTRAL_BASE_URL=https://your-vllm-server:8000/v1 # required +VOXTRAL_API_KEY=your-key # if your server enforces one +# VOXTRAL_MODEL=mistralai/Voxtral-Mini-4B-Realtime-2602 # default +``` + +VAD and segmentation tuning options (silence duration, pre-roll, split +overlap, max segment length) are documented in `.env.example`. + +> **Note**: Voxtral Realtime does not support real-time translation. Only the +> original transcript language is returned, matching the user's BBB speech +> locale. + ### Development #### Testing diff --git a/providers/voxtral_realtime.py b/providers/voxtral_realtime.py index 2ce2357..a701329 100644 --- a/providers/voxtral_realtime.py +++ b/providers/voxtral_realtime.py @@ -2,7 +2,9 @@ vLLM's protocol differs from the OpenAI Realtime Transcription API in three ways: - session.update: model is at the top level, not nested inside session.audio -- No server-side VAD: client must send input_audio_buffer.commit to trigger generation +- No server-side VAD: the client segments speech itself — a bare + input_audio_buffer.commit opens a streaming request, commit(final: true) + closes it (verified in notes/progressive-transcription-investigation.md) - Response events: transcription.delta / transcription.done (not conversation.item.*) Audio must be PCM16, 16 kHz, mono, base64-encoded. @@ -27,11 +29,16 @@ from providers.base import BaseSttAgent, BaseSttConfig # Safety cap on a single streaming request's length. Only hit during pure -# monologue (no VAD pause); each cap-triggered split risks cutting a word, so it -# is set high to make splits rare. Lower it only to commit FINAL transcripts -# sooner during long continuous speech (interim captions stream regardless). +# monologue (no VAD pause). It serves two purposes: BBB only commits a caption +# on transcription.done, so the cap bounds FINAL latency and caption size when +# a speaker never pauses; and vLLM resets its position counter per commit +# cycle (not per connection), so the cap also bounds per-request context — +# exceeding --max-model-len crashes the whole engine. At 80 ms/token, 30 s is +# ~375 tokens, far from any realistic limit; splits replay _SPLIT_OVERLAP_S so +# lowering the cap mainly trades boundary-word duplication for faster FINALs. _MAX_BUFFER_DURATION_S = float(os.getenv("VOXTRAL_MAX_BUFFER_DURATION_S", "30.0")) -_TARGET_SAMPLE_RATE = int(os.getenv("VOXTRAL_TARGET_SAMPLE_RATE", "16000")) +# The model requires 16 kHz mono PCM16; deliberately not configurable. +_TARGET_SAMPLE_RATE = 16000 # Silero VAD parameters — replace the old RMS threshold and silence duration _VAD_MIN_SILENCE_S = float(os.getenv("VOXTRAL_VAD_MIN_SILENCE_S", "0.6")) _VAD_ACTIVATION_THRESHOLD = float(os.getenv("VOXTRAL_VAD_ACTIVATION_THRESHOLD", "0.5")) @@ -81,6 +88,14 @@ def __init__( vad: agents_vad.VAD | None = None, ): super().__init__(config) + if not config.base_url: + # Fail at startup rather than with confusing auth/protocol errors + # mid-meeting: Voxtral Realtime is served by a self-hosted vLLM + # instance, never by api.openai.com. + raise ValueError( + "VOXTRAL_BASE_URL is required for the voxtral-realtime provider " + "(the URL of your vLLM server, e.g. https://your-server:8000/v1)." + ) self._http_session: aiohttp.ClientSession | None = None self._vad: agents_vad.VAD = vad or silero.VAD.load( min_silence_duration=_VAD_MIN_SILENCE_S, @@ -95,7 +110,7 @@ def _get_http_session(self) -> aiohttp.ClientSession: return self._http_session def _build_ws_url(self) -> str: - base = (self.config.base_url or "https://api.openai.com/v1").rstrip("/") + base = self.config.base_url.rstrip("/") base = base.replace("https://", "wss://", 1).replace("http://", "ws://", 1) return f"{base}/realtime?intent=transcription" @@ -197,7 +212,11 @@ async def _run_transcription_pipeline( except asyncio.CancelledError: raise - except (aiohttp.ClientError, ConnectionResetError) as e: + except (TimeoutError, aiohttp.ClientError, ConnectionResetError) as e: + # TimeoutError covers a slow session.created handshake — + # vLLM takes 2–5 min of CUDA-graph warmup after startup, + # during which giving up permanently would cost the + # participant the whole meeting. Retry with backoff. logging.warning( f"Voxtral WS connection lost for {participant.identity} " f"({type(e).__name__}: {e}), reconnecting in {retry_delay:.0f}s" diff --git a/tests/test_voxtral_agent.py b/tests/test_voxtral_agent.py index c701c69..91916e9 100644 --- a/tests/test_voxtral_agent.py +++ b/tests/test_voxtral_agent.py @@ -37,6 +37,7 @@ def _make_mock_vad(vad_events=None): def _make_config(**kwargs): + kwargs.setdefault("base_url", "https://test-server.example.com/v1") return VoxtralRealtimeConfig(api_key="test-key", **kwargs) @@ -149,12 +150,14 @@ def test_custom_base_url_via_env(self, monkeypatch): class TestBuildWsUrl: - def test_default_url(self): - agent = _make_agent() - assert ( - agent._build_ws_url() - == "wss://api.openai.com/v1/realtime?intent=transcription" - ) + def test_missing_base_url_raises_at_agent_creation(self): + """Voxtral is self-hosted; defaulting to api.openai.com can only + produce confusing auth/protocol errors mid-meeting. Fail at startup.""" + with pytest.raises(ValueError, match="VOXTRAL_BASE_URL"): + VoxtralRealtimeSttAgent( + VoxtralRealtimeConfig(api_key="test-key", base_url=None), + vad=_make_mock_vad(), + ) def test_custom_https_url_becomes_wss(self): agent = _make_agent(base_url="https://my-server.example.com/v1") @@ -1061,6 +1064,54 @@ async def test_server_error_event_closes_ws_and_reconnects(self, monkeypatch): ) +class TestHandshakeTimeout: + async def test_slow_session_created_retries_instead_of_giving_up(self, monkeypatch): + """ + Regression for permanent give-up during server warmup: vLLM takes + minutes of CUDA-graph warmup after startup, during which the WS may + connect but session.created arrives late. A handshake timeout must + retry with backoff like a connection error — not end transcription + for the participant's whole meeting. + """ + import providers.voxtral_realtime as vr + + monkeypatch.setattr(vr, "_RETRY_DELAY_INITIAL_S", 0.01) + + agent = _make_agent(vad_events=[]) + participant = MagicMock(spec=rtc.RemoteParticipant) + participant.identity = "user_warmup" + + # First connection: session.created never arrives (handshake timeout). + slow_ws = AsyncMock() + slow_ws.receive = AsyncMock(side_effect=asyncio.TimeoutError) + ws2 = _ScriptedWs([_text_ws_msg({"type": "session.created"})]) + + mock_session = MagicMock() + mock_session.ws_connect = MagicMock( + side_effect=[_ws_context(slow_ws), _ws_context(ws2)] + ) + agent._http_session = mock_session + + empty = AsyncMock() + empty.__aiter__.return_value = iter([]) + empty.aclose = AsyncMock() + empty2 = AsyncMock() + empty2.__aiter__.return_value = iter([]) + empty2.aclose = AsyncMock() + + with patch( + "providers.voxtral_realtime.rtc.AudioStream", side_effect=[empty, empty2] + ): + await asyncio.wait_for( + agent._run_transcription_pipeline(participant, MagicMock(), "en"), + timeout=5.0, + ) + + assert mock_session.ws_connect.call_count == 2, ( + "a handshake timeout must reconnect with backoff, not give up" + ) + + class TestLocaleUpdateRace: async def test_locale_change_does_not_orphan_replacement_pipeline(self): """ From ad51fc45e51fa3c564bab03e5968e77a968d38a5 Mon Sep 17 00:00:00 2001 From: timo Date: Tue, 7 Jul 2026 15:56:14 +0200 Subject: [PATCH 10/11] fix(voxtral): request greedy decoding (temperature 0.0) in session.update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Voxtral model card mandates "always set the temperature to 0.0" — greedy decoding is required for deterministic, stable transcription — but session.update only carries the model, leaving sampling temperature to whatever the server defaults to. vLLM's session.update shape for temperature is undocumented, so the field was probed rather than sent blind (probe test 6): the server accepts temperature at the TOP level alongside model and still transcribes normally, while the OpenAI-style nested {"session": {...}} shape is rejected with "Missing required field: model" — incidentally hard-confirming the flat-shape assumption the provider was built on. Whether the server honors or ignores the field is not observable from the protocol; sending it is at worst a no-op. --- CHANGELOG.md | 1 + providers/voxtral_realtime.py | 12 ++++++++++-- tests/test_voxtral_agent.py | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 410f012..98f8126 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Final releases will consolidate all intermediate changes in chronological order. * fix(voxtral): keep replacement pipeline tracked across locale-change restarts * fix(voxtral): resample with rtc.AudioResampler to stop aliasing into the speech band * fix(voxtral): retry on handshake timeout, require VOXTRAL_BASE_URL at startup +* fix(voxtral): request greedy decoding (temperature 0.0) in session.update * docs: document the Voxtral Realtime provider and Python 3.11 requirement in README * build: add GitHub Actions workflow for running tests ## v0.2.0 diff --git a/providers/voxtral_realtime.py b/providers/voxtral_realtime.py index a701329..080d095 100644 --- a/providers/voxtral_realtime.py +++ b/providers/voxtral_realtime.py @@ -200,9 +200,17 @@ async def _run_transcription_pipeline( # Connection is healthy again; reset reconnect backoff. retry_delay = _RETRY_DELAY_INITIAL_S - # vLLM expects model at top level of session.update + # vLLM expects a FLAT session.update — model and + # temperature at the top level; nesting under + # "session" is rejected (probe test 6). The model + # card mandates temperature 0.0: greedy decoding is + # required for stable transcription. await ws.send_json( - {"type": "session.update", "model": self.config.model} + { + "type": "session.update", + "model": self.config.model, + "temperature": 0.0, + } ) await self._vad_loop( diff --git a/tests/test_voxtral_agent.py b/tests/test_voxtral_agent.py index 91916e9..4fd2f81 100644 --- a/tests/test_voxtral_agent.py +++ b/tests/test_voxtral_agent.py @@ -1064,6 +1064,40 @@ async def test_server_error_event_closes_ws_and_reconnects(self, monkeypatch): ) +class TestSessionUpdate: + async def test_sends_flat_model_and_greedy_temperature(self): + """vLLM requires a FLAT session.update (nesting under "session" is + rejected with "Missing required field: model" — probe test 6), and + the model card mandates temperature 0.0 for stable transcription.""" + agent = _make_agent(vad_events=[]) + participant = MagicMock(spec=rtc.RemoteParticipant) + participant.identity = "user_cfg" + + ws = _ScriptedWs([_text_ws_msg({"type": "session.created"})]) + mock_session = MagicMock() + mock_session.ws_connect = MagicMock(return_value=_ws_context(ws)) + agent._http_session = mock_session + + empty = AsyncMock() + empty.__aiter__.return_value = iter([]) + empty.aclose = AsyncMock() + + with patch("providers.voxtral_realtime.rtc.AudioStream", return_value=empty): + await asyncio.wait_for( + agent._run_transcription_pipeline(participant, MagicMock(), "en"), + timeout=5.0, + ) + + updates = [m for m in ws.sent if m.get("type") == "session.update"] + assert updates == [ + { + "type": "session.update", + "model": agent.config.model, + "temperature": 0.0, + } + ] + + class TestHandshakeTimeout: async def test_slow_session_created_retries_instead_of_giving_up(self, monkeypatch): """ From 19def9721ea122b3dd4742f90fba9c5a8d2c52e8 Mon Sep 17 00:00:00 2001 From: timo Date: Thu, 9 Jul 2026 13:18:18 +0200 Subject: [PATCH 11/11] fix(voxtral): gate opening commits until the previous segment's done arrives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vLLM's realtime handler runs one generation per connection and silently drops any commit that arrives while the previous segment's generation is still running ("Generation already in progress, ignoring commit"). The writer sends commits on VAD/cap timing alone, so two paths collide with this: a max-buffer split reopens on the very next frame — while the server is guaranteed to still be decoding the closed segment — and a back-to-back utterance can open before the previous done (the decode tail takes 0.5–2 s). A dropped opener costs the segment its live interim captions (the audio is batch-transcribed at the closing commit instead); a dropped closer loses the segment's transcription.done entirely, desyncing the FIFO start-time pairing so later captions merge or overwrite each other. The damage compounds within a connection's lifetime, which is why transcription quality degrades over time and hits long utterances hardest. Track the number of opened segments whose transcription.done has not been read yet and defer opening commits while it is non-zero. Audio arriving during the wait accumulates in a gate buffer (seeded with the usual onset/split lead-in) and is replayed once the segment opens, so no audio is lost — captions for the gated segment just start slightly later. An utterance that ends while gated is closed right after it finally opens. A bounded timeout guards against a done that never arrives (an already-desynced session): the counter is resynced and the open proceeds ungated, i.e. the previous behavior. An alternative considered was retrying the opener until the server stops warning, but the server gives the client no feedback when it ignores a commit — the drop is only visible in server logs — so the client must serialize commit cycles itself. --- .env.example | 8 + CHANGELOG.md | 1 + providers/voxtral_realtime.py | 132 ++++++++++++-- tests/test_voxtral_agent.py | 326 ++++++++++++++++++++++++++++++++++ 4 files changed, 449 insertions(+), 18 deletions(-) diff --git a/.env.example b/.env.example index cb261f6..6a7417c 100644 --- a/.env.example +++ b/.env.example @@ -107,5 +107,13 @@ GLADIA_TRANSLATION_LANG_MAP="de:de-DE,en:en-US,es:es-ES,fr:fr-FR,hi:hi-IN,it:it- # final transcripts during continuous speech (live interim captions are unaffected). #VOXTRAL_MAX_BUFFER_DURATION_S=30.0 +# How long to defer a segment's opening commit while the server is still +# generating the previous segment's transcript (default: 10.0). vLLM silently +# ignores commits sent during an in-flight generation, so the opener waits for +# the previous transcription.done; audio is buffered locally and replayed, so +# nothing is lost. On timeout (a done that never arrives) the pipeline resyncs +# and opens anyway. +#VOXTRAL_OPEN_GATE_TIMEOUT_S=10.0 + # Emit incremental transcription.delta events as interim captions (default: true) #VOXTRAL_INTERIM_RESULTS=true diff --git a/CHANGELOG.md b/CHANGELOG.md index 98f8126..a976705 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Final releases will consolidate all intermediate changes in chronological order. * fix(voxtral): resample with rtc.AudioResampler to stop aliasing into the speech band * fix(voxtral): retry on handshake timeout, require VOXTRAL_BASE_URL at startup * fix(voxtral): request greedy decoding (temperature 0.0) in session.update +* fix(voxtral): gate opening commits until the previous segment's transcription.done arrives * docs: document the Voxtral Realtime provider and Python 3.11 requirement in README * build: add GitHub Actions workflow for running tests ## v0.2.0 diff --git a/providers/voxtral_realtime.py b/providers/voxtral_realtime.py index 080d095..d01a155 100644 --- a/providers/voxtral_realtime.py +++ b/providers/voxtral_realtime.py @@ -58,6 +58,16 @@ # transcription.done of the final segment before tearing the reader down; # cancelling it immediately would drop the tail utterance's FINAL. _FINAL_DRAIN_TIMEOUT_S = 3.0 +# vLLM's realtime handler runs one generation per connection and SILENTLY +# drops any commit that arrives while the previous segment's generation is +# still running ("Generation already in progress, ignoring commit"). A dropped +# opener means the segment never streams (no interims, late batched FINAL); a +# dropped closer loses the segment's transcription.done entirely and desyncs +# the FIFO pairing. Openers are therefore gated until the previous segment's +# done has been read, buffering audio locally meanwhile. The timeout bounds +# the wait when a done never arrives (already-desynced session): give up, +# resync the counter, and open ungated as before. +_OPEN_GATE_TIMEOUT_S = float(os.getenv("VOXTRAL_OPEN_GATE_TIMEOUT_S", "10.0")) @dataclass @@ -284,6 +294,12 @@ async def _vad_loop( seg_text = "" seg_start: float | None = None + # Segments opened whose transcription.done has not been read yet. + # Incremented by _writer._open(), decremented by _reader on done. + # _writer defers opening commits while this is non-zero — vLLM ignores + # commits sent during an in-flight generation (see _OPEN_GATE_TIMEOUT_S). + outstanding = 0 + def _pop_segment_start(context: str) -> float: if segment_starts: return segment_starts.popleft() @@ -331,7 +347,7 @@ async def _reader() -> None: emitted by the server while audio is still streaming are consumed in real time rather than buffered and replayed after each commit. """ - nonlocal seg_text, seg_start + nonlocal seg_text, seg_start, outstanding while True: try: @@ -386,6 +402,9 @@ async def _reader() -> None: _emit_transcript(False, seg_text, seg_start) elif msg_type == "transcription.done": + # The server finished this segment's generation; the writer + # may open the next segment's request now. + outstanding = max(0, outstanding - 1) # Prefer accumulated delta text over done.text: the realtime # API sends content via deltas; done.text may be empty or absent. server_text = data.get("text", "").strip() @@ -486,6 +505,7 @@ async def _writer() -> None: # buffer holds only the current utterance, so the longer replay # is safe and gives the reopened request the left-context the # model needs (see _SPLIT_OVERLAP_S). + nonlocal outstanding onset_max = ( int(min(_VAD_PREROLL_S, _VAD_MIN_SILENCE_S) * _TARGET_SAMPLE_RATE) * 2 ) @@ -497,6 +517,17 @@ async def _writer() -> None: open_secs = 0.0 # duration of the current open segment stream_open = False normalizer = _AudioNormalizer() # stateful: one per audio stream + # Commit gate (see _OPEN_GATE_TIMEOUT_S): while the previous + # segment's transcription.done is outstanding, an opening commit + # would be silently ignored by vLLM, so it is deferred. gate_buf + # captures the lead-in plus all audio arriving during the wait and + # becomes the eventual _open() replay — the segment starts a + # little later but loses nothing. + gated = False + gate_buf: bytearray | None = None + gate_deadline = 0.0 + pending_end = False # END_OF_SPEECH arrived while gated + loop = asyncio.get_running_loop() async def _append(data: bytes) -> None: await ws.send_json( @@ -512,22 +543,32 @@ async def _flush_pending() -> None: await _append(pending[:chunk_size]) pending = pending[chunk_size:] - async def _open() -> None: - """Open a streaming request and replay the captured lead-in.""" - nonlocal stream_open, pending, open_secs, split_reopen - commit_event.clear() # discard any stale END from a prior segment + def _take_lead_in() -> bytes: + """Consume the rolling buffer's tail as a new segment's lead-in.""" + nonlocal split_reopen limit = split_max if split_reopen else onset_max split_reopen = False - replay = ( + lead = ( bytes(preroll[max(0, len(preroll) - limit) :]) if limit > 0 else b"" ) preroll.clear() + return lead + + async def _open(replay: bytes) -> None: + """Open a streaming request and replay the captured lead-in.""" + nonlocal stream_open, pending, open_secs, outstanding + commit_event.clear() # discard any stale END from a prior segment # Record this segment's start (backdated by the replayed lead-in) # for the reader to pair with the segment's transcription events. replay_secs = len(replay) / (_TARGET_SAMPLE_RATE * 2) segment_starts.append(time.time() - open_time - replay_secs) await ws.send_json({"type": "input_audio_buffer.commit"}) + outstanding += 1 stream_open = True + # The replay does not count toward the max-buffer cap: it is + # bounded on its own (lead-in caps, gate timeout), and counting + # it would make a long gated replay close the segment the + # moment it opens. open_secs = 0.0 if replay: pending += replay @@ -566,22 +607,62 @@ async def _close() -> None: resampled = normalizer.process(frame) - # An END arriving while no request is open means the - # cap-split utterance finished before the reopen (or a - # prior segment's END is stale): whatever opens next is a - # fresh onset, not a split continuation. + # An END arriving while no request is open: if a gated + # segment is waiting, the utterance finished before the + # server freed up — remember to close it right after it + # opens. Otherwise it is stale (cap-split utterance ended + # before the reopen): whatever opens next is a fresh + # onset, not a split continuation. if not stream_open and commit_event.is_set(): commit_event.clear() - split_reopen = False - - # Open a request as soon as the VAD reports speech. - if is_in_speech and not stream_open: - await _open() + if gated: + pending_end = True + else: + split_reopen = False + + # Open a request as soon as the VAD reports speech — but + # only once the server has finished the previous segment's + # generation; a commit sent earlier would be silently + # ignored. While deferred, audio accumulates in gate_buf. + if not stream_open and (is_in_speech or gated): + if outstanding == 0: + replay = ( + bytes(gate_buf) + if gate_buf is not None + else _take_lead_in() + ) + gate_buf = None + gated = False + await _open(replay) + if pending_end: + pending_end = False + # The gated utterance already ended; close it + # now — unless the speaker has resumed, in + # which case keep streaming and let the next + # END close the merged segment. + if not is_in_speech: + await _close() + elif not gated: + gated = True + gate_deadline = loop.time() + _OPEN_GATE_TIMEOUT_S + gate_buf = bytearray(_take_lead_in()) + elif loop.time() >= gate_deadline: + # The done never arrived (lost closing commit or + # event desync); waiting longer only buffers more + # audio. Resync and open ungated on the next frame. + logging.warning( + f"Voxtral: open gate timed out for " + f"{participant.identity} with {outstanding} " + f"transcription(s) outstanding — resyncing" + ) + outstanding = 0 if stream_open: pending += resampled open_secs += frame_duration await _flush_pending() + elif gated: + gate_buf += resampled # Keep a bounded rolling buffer of the most recent audio, # whether idle or open. _open() replays its tail as the @@ -612,10 +693,19 @@ async def _close() -> None: # request would be dropped without its closing commit — losing # the utterance's FINAL and leaving the server request dangling # (abrupt drops are a known vLLM realtime crash trigger). - # Best-effort close before propagating the cancellation. - if stream_open: + # Best-effort close before propagating the cancellation. A + # gated segment never sent its opener, so open-and-close it — + # the server may still be mid-generation and drop the opener, + # but that is no worse than losing the utterance outright. + if stream_open or (gated and gate_buf): try: - await asyncio.wait_for(_close(), timeout=1.0) + + async def _cancel_flush() -> None: + if not stream_open: + await _open(bytes(gate_buf)) + await _close() + + await asyncio.wait_for(_cancel_flush(), timeout=1.0) except Exception: logging.debug( f"Voxtral: cancel-time flush failed for " @@ -624,9 +714,15 @@ async def _close() -> None: raise # End of stream: drain the resampler tail and flush any open request. + # A gated segment's opener was deferred the whole time; send it now, + # best-effort, so the buffered utterance still gets transcribed. if stream_open: pending += normalizer.flush() await _close() + elif gated and gate_buf: + gate_buf += normalizer.flush() + await _open(bytes(gate_buf)) + await _close() vad_stream.end_input() # ── Run all three tasks concurrently ────────────────────────────────── diff --git a/tests/test_voxtral_agent.py b/tests/test_voxtral_agent.py index 4fd2f81..0e12a3f 100644 --- a/tests/test_voxtral_agent.py +++ b/tests/test_voxtral_agent.py @@ -953,6 +953,332 @@ async def test_fresh_onset_replay_is_capped(self, monkeypatch): ) +# ── Commit gate ──────────────────────────────────────────────────────────────── + + +class _ScheduledVadStream: + """VAD double firing scripted events after N pushed frames.""" + + def __init__(self, schedule): + # schedule: list of (after_pushed_frames, VADEventType) + self._schedule = list(schedule) + self.pushed = 0 + + def push_frame(self, _frame): + self.pushed += 1 + + def end_input(self): + pass + + async def aclose(self): + pass + + def __aiter__(self): + return self + + async def __anext__(self): + while True: + if not self._schedule: + await asyncio.Event().wait() # block until cancelled + after, ev_type = self._schedule[0] + if self.pushed >= after: + self._schedule.pop(0) + return _make_vad_event(ev_type) + await asyncio.sleep(0) + + +class TestCommitGate: + """vLLM silently drops a commit sent while the previous segment's + generation is still running ("Generation already in progress, ignoring + commit") — the segment then never streams, or loses its transcription.done + entirely. Opening commits must wait for the previous segment's done.""" + + _RELEASE_MARKER = {"type": "_test_done_released"} + + def _wire(self, agent, frames, script): + """Wire agent to a scripted WS; returns (participant, stream, sent). + + script: list of (condition, message, mark) — receive() serves each + message once its condition (over `sent`) holds; mark=True appends + _RELEASE_MARKER to `sent` first, recording the release moment in the + send/receive order. + """ + participant = MagicMock(spec=rtc.RemoteParticipant) + participant.identity = "user_gate" + + sent: list[dict] = [] + + async def _send_json(data): + sent.append(data) + await asyncio.sleep(0) + + closed_msg = MagicMock() + closed_msg.type = aiohttp.WSMsgType.CLOSED + script_iter = iter(script) + + async def _receive(*args, **kwargs): + try: + cond, msg, mark = next(script_iter) + except StopIteration: + return closed_msg + while not cond(): + await asyncio.sleep(0) + if mark: + sent.append(dict(self._RELEASE_MARKER)) + return msg + + mock_ws = AsyncMock() + mock_ws.receive = _receive + mock_ws.send_json = _send_json + cm = AsyncMock() + cm.__aenter__ = AsyncMock(return_value=mock_ws) + cm.__aexit__ = AsyncMock(return_value=False) + mock_session = MagicMock() + mock_session.ws_connect = MagicMock(return_value=cm) + agent._http_session = mock_session + + audio_events = [MagicMock(frame=f) for f in frames] + mock_stream = AsyncMock() + mock_stream.__aiter__.return_value = iter(audio_events) + mock_stream.aclose = AsyncMock() + + return participant, mock_stream, sent + + @staticmethod + def _openers(sent): + return [ + i + for i, m in enumerate(sent) + if m.get("type") == "input_audio_buffer.commit" and "final" not in m + ] + + @staticmethod + def _closers(sent): + return [ + i + for i, m in enumerate(sent) + if m.get("type") == "input_audio_buffer.commit" and m.get("final") is True + ] + + def _marker_index(self, sent): + return next(i for i, m in enumerate(sent) if m == self._RELEASE_MARKER) + + async def test_opener_waits_for_previous_done_and_loses_no_audio(self, monkeypatch): + """ + Regression for the dropped-commit degradation: after a max-buffer + split, the reopen's opening commit must NOT be sent until the previous + segment's transcription.done has been read — vLLM ignores commits + during an in-flight generation, which cost the reopened segment its + streaming (and, on a swallowed closer, its FINAL). The audio arriving + during the wait must be buffered and replayed, not dropped. + """ + import providers.voxtral_realtime as vr + + monkeypatch.setattr(vr, "_MAX_BUFFER_DURATION_S", 0.015) + + vad_stream = _ScheduledVadStream([(1, agents_vad.VADEventType.START_OF_SPEECH)]) + mock_vad = MagicMock() + mock_vad.stream.return_value = vad_stream + agent = VoxtralRealtimeSttAgent(_make_config(), vad=mock_vad) + + frames = [_make_audio_frame(amplitude=100 + i) for i in range(8)] + + # Hold segment 1's transcription until well after the split close + # (6 frames pushed), so an ungated reopen would fire first. + sent_holder: dict = {} + + def _cond_done1(): + sent = sent_holder["sent"] + return vad_stream.pushed >= 6 and self._closers(sent) + + def _cond_seg2(): + return len(self._openers(sent_holder["sent"])) >= 2 + + script = [ + (lambda: True, _text_ws_msg({"type": "session.created"}), False), + ( + _cond_done1, + _text_ws_msg({"type": "transcription.delta", "delta": "hello"}), + True, + ), + ( + lambda: True, + _text_ws_msg({"type": "transcription.done", "text": "hello"}), + False, + ), + ( + _cond_seg2, + _text_ws_msg({"type": "transcription.delta", "delta": "world"}), + False, + ), + ( + lambda: True, + _text_ws_msg({"type": "transcription.done", "text": "world"}), + False, + ), + ] + + participant, mock_stream, sent2 = self._wire(agent, frames, script) + sent_holder["sent"] = sent2 + + final = [] + agent.on("final_transcript", lambda **kw: final.append(kw)) + + with patch( + "providers.voxtral_realtime.rtc.AudioStream", return_value=mock_stream + ): + await asyncio.wait_for( + agent._run_transcription_pipeline(participant, MagicMock(), "en"), + timeout=5.0, + ) + await asyncio.sleep(0) + + openers = self._openers(sent2) + assert len(openers) >= 2, f"expected a reopen, got {len(openers)} opener(s)" + marker = self._marker_index(sent2) + assert openers[1] > marker, ( + "the reopen's opening commit must be deferred until the previous " + "segment's transcription.done has been received — an earlier " + "commit is silently dropped by vLLM" + ) + + # No audio lost while gated: the frames that arrived while the opener + # was deferred (the gate window right after the split close) must be + # replayed contiguously into the reopened segment. + segments = _segments_from_sent(sent2) + assert len(segments) >= 2 + gated_audio = b"".join(f.data for f in frames[2:5]) + assert gated_audio in segments[1], ( + "audio arriving while the opener was gated must be buffered and " + "replayed into the reopened segment, not dropped" + ) + + texts = [kw["event"].alternatives[0].text for kw in final] + assert texts == ["hello", "world"] + + async def test_utterance_ending_while_gated_is_still_sent(self): + """ + A short utterance that starts AND ends while the previous segment's + done is outstanding must still be sent (open + close) once the server + frees up — not stay buffered forever or be dropped. + """ + vad_stream = _ScheduledVadStream( + [ + (1, agents_vad.VADEventType.START_OF_SPEECH), + (3, agents_vad.VADEventType.END_OF_SPEECH), + (6, agents_vad.VADEventType.START_OF_SPEECH), + (9, agents_vad.VADEventType.END_OF_SPEECH), + ] + ) + mock_vad = MagicMock() + mock_vad.stream.return_value = vad_stream + agent = VoxtralRealtimeSttAgent(_make_config(), vad=mock_vad) + + frames = [_make_audio_frame(amplitude=100 + i) for i in range(16)] + + sent_holder: dict = {} + + def _cond_done1(): + sent = sent_holder["sent"] + return vad_stream.pushed >= 12 and self._closers(sent) + + def _cond_seg2(): + return len(self._openers(sent_holder["sent"])) >= 2 + + script = [ + (lambda: True, _text_ws_msg({"type": "session.created"}), False), + ( + _cond_done1, + _text_ws_msg({"type": "transcription.delta", "delta": "hello"}), + True, + ), + ( + lambda: True, + _text_ws_msg({"type": "transcription.done", "text": "hello"}), + False, + ), + ( + _cond_seg2, + _text_ws_msg({"type": "transcription.delta", "delta": "world"}), + False, + ), + ( + lambda: True, + _text_ws_msg({"type": "transcription.done", "text": "world"}), + False, + ), + ] + + participant, mock_stream, sent = self._wire(agent, frames, script) + sent_holder["sent"] = sent + + final = [] + agent.on("final_transcript", lambda **kw: final.append(kw)) + + with patch( + "providers.voxtral_realtime.rtc.AudioStream", return_value=mock_stream + ): + await asyncio.wait_for( + agent._run_transcription_pipeline(participant, MagicMock(), "en"), + timeout=5.0, + ) + await asyncio.sleep(0) + + openers = self._openers(sent) + closers = self._closers(sent) + assert len(openers) == 2 and len(closers) == 2, ( + f"gated utterance must be opened and closed once the server frees " + f"up — got {len(openers)} openers / {len(closers)} closers" + ) + marker = self._marker_index(sent) + assert openers[1] > marker, ( + "utterance 2's opener must wait for utterance 1's done" + ) + texts = [kw["event"].alternatives[0].text for kw in final] + assert texts == ["hello", "world"] + + async def test_gate_timeout_resyncs_and_opens(self, monkeypatch, caplog): + """ + When a transcription.done never arrives (swallowed closing commit, + already-desynced session), the gate must not wedge the pipeline: after + the timeout it resyncs the outstanding counter and opens ungated — + degrading to the pre-gate behavior instead of buffering forever. + """ + import providers.voxtral_realtime as vr + + monkeypatch.setattr(vr, "_MAX_BUFFER_DURATION_S", 0.015) + monkeypatch.setattr(vr, "_OPEN_GATE_TIMEOUT_S", 0.0) + + vad_stream = _ScheduledVadStream([(1, agents_vad.VADEventType.START_OF_SPEECH)]) + mock_vad = MagicMock() + mock_vad.stream.return_value = vad_stream + agent = VoxtralRealtimeSttAgent(_make_config(), vad=mock_vad) + + frames = [_make_audio_frame(amplitude=100 + i) for i in range(8)] + script = [ + (lambda: True, _text_ws_msg({"type": "session.created"}), False), + # No transcription events ever — the done is lost. + ] + participant, mock_stream, sent = self._wire(agent, frames, script) + + with ( + patch( + "providers.voxtral_realtime.rtc.AudioStream", + return_value=mock_stream, + ), + caplog.at_level("WARNING"), + ): + await asyncio.wait_for( + agent._run_transcription_pipeline(participant, MagicMock(), "en"), + timeout=5.0, + ) + + assert "open gate timed out" in caplog.text + assert len(self._openers(sent)) >= 2, ( + "after the gate timeout the next segment must still open" + ) + + # ── Failure recovery and teardown flush ────────────────────────────────────────