diff --git a/translation-demo/.env.example b/translation-demo/.env.example index ccc4769..2b61f38 100644 --- a/translation-demo/.env.example +++ b/translation-demo/.env.example @@ -2,3 +2,12 @@ # the sandbox (the relay host, the Fishjam ID root, and the JWT embedded as `?jwt=`) and connects # to it directly. Get it at https://fishjam.io/app/sandbox. VITE_SANDBOX_API_URL=https://fishjam.io/api/v1/connect//room-manager + +# Translation service (server-side only — never expose these with a VITE_ prefix). +# The service in ./service fetches a MoQ token from Fishjam with a management token +# and translates every stream in the account. Get both at https://fishjam.io/app. +FISHJAM_ID= +FISHJAM_MANAGEMENT_TOKEN= + +# Gemini API key for the translation service. +GEMINI_API_KEY= diff --git a/translation-demo/.gitignore b/translation-demo/.gitignore index 09a7283..8ff593d 100644 --- a/translation-demo/.gitignore +++ b/translation-demo/.gitignore @@ -9,6 +9,10 @@ dist-ssr # Env .env +# Python (translation service) +service/.venv +__pycache__/ + # Yarn (Berry) .yarn/* !.yarn/patches diff --git a/translation-demo/README.md b/translation-demo/README.md index a732cca..a0c8b24 100644 --- a/translation-demo/README.md +++ b/translation-demo/README.md @@ -1,28 +1,57 @@ # Translation Demo -An example React app that streams over the MoQ protocol with Fishjam, with live audio translation and captions for the viewer. +Live MoQ streaming with real-time AI audio translation and captions, built on +Fishjam. The demo has two parts: -## Getting Started +- **Web app** — a React app for publishing a stream and + watching it, with a translation menu and live captions on the watch page. +- **Translation service** (`service/`) — a Python service that subscribes to + every stream and announces AI-translated audio and caption tracks + which the web app picks up. -Install dependencies: +Both are needed for translations: the watch page's translation menu only shows +languages while the service is running. + +## Prerequisites + +- A Fishjam account (https://fishjam.io/app) — you'll need the sandbox API URL, + your Fishjam ID, and a management token. +- A Gemini API key for the translation provider. +- Node.js with Yarn, Python 3.11+, and [uv](https://docs.astral.sh/uv/). + +## Setup + +Configure credentials once for both parts: ```bash -yarn +cp .env.example .env +# fill in VITE_SANDBOX_API_URL, FISHJAM_ID, FISHJAM_MANAGEMENT_TOKEN, +# and GEMINI_API_KEY ``` -Configure the sandbox API URL (required): +## Run the translation service ```bash -cp .env.example .env -# then set VITE_SANDBOX_API_URL in .env to your Fishjam sandbox API URL +cd service +uv sync +uv run --env-file ../.env translator ``` -Start the development server: +Translation is powered by Google Gemini Live. See `service/README.md` for details. + +## Run the web app ```bash +yarn yarn dev ``` +Open the printed URL and publish a stream, then open the viewer link shown in +the publisher panel (the watch page) in another tab and pick a translation +language. + ## Environment Variables -- `VITE_SANDBOX_API_URL` (required) — Fishjam sandbox API URL used to fetch a MoQ relay connection URL. Get it at https://fishjam.io/app/sandbox. +- `VITE_SANDBOX_API_URL` (web app) — Fishjam sandbox API URL used to fetch a MoQ relay connection URL. Get it at https://fishjam.io/app/sandbox. +- `FISHJAM_ID`, `FISHJAM_MANAGEMENT_TOKEN` (translation service) — Fishjam credentials used to mint MoQ tokens. Get them at https://fishjam.io/app. +- `GEMINI_API_KEY` (translation service) — Google Gemini API key used for translation. diff --git a/translation-demo/service/.python-version b/translation-demo/service/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/translation-demo/service/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/translation-demo/service/README.md b/translation-demo/service/README.md new file mode 100644 index 0000000..312e386 --- /dev/null +++ b/translation-demo/service/README.md @@ -0,0 +1,62 @@ +# Translation Service + +Async Python service that announces live AI-translated audio and captions for +MoQ audio broadcasts, powered by Google Gemini Live. + +For each source broadcast announced by the relay, the service reads the Hang +catalog, finds a supported audio track, and announces a sibling dynamic +broadcast at: + +```text +//translation +``` + +Subscribers request a target language by subscribing to a track named with the +language code, for example `es` or `pt-BR`. Translation starts on demand: the +provider session opens when the first subscriber requests a language and closes +shortly after the last one leaves. + +While a translated audio track is active, subscribers can request its live +transcript on a track named `/transcript.json`. Transcript frames are +UTF-8 JSON replace-state payloads: + +```json +{"segments": [{"text": "Hola", "ts_us": 0, "final": false}]} +``` + +## Run + +```bash +uv sync +uv run --env-file ../.env translator +``` + +The service fetches a MoQ token from Fishjam using a management token and +reconnects with a fresh token before the hourly expiry. A single instance +covers all streams in the account, including ones published after it starts. + +## Environment variables + +- `FISHJAM_ID`, `FISHJAM_MANAGEMENT_TOKEN` — Fishjam credentials used to mint + MoQ tokens. Get them at https://fishjam.io/app. +- `GEMINI_API_KEY` — Google Gemini API key (`GOOGLE_API_KEY` is accepted as a + fallback). + +## Useful flags + +- `--prefix` — only watch broadcasts under this path prefix (default: all + streams in the account). +- `--google-model` — override the Gemini Live translation model (default + `models/gemini-3.5-live-translate-preview`). +- `--url` — connect directly to a MoQ relay without Fishjam authentication + (local development); combine with `--no-tls-verify` for self-signed relays. +- `--log-level DEBUG` — verbose logging. + +## Notes + +- Supported target languages come from the Gemini Live Translate BCP-47 + language list; unsupported language requests are closed without opening a + provider session. +- Opus and AAC sources are supported. Output mirrors the source codec family. +- Transcript tracks are scoped to an active audio language track and close when + that audio translation stops. diff --git a/translation-demo/service/pyproject.toml b/translation-demo/service/pyproject.toml new file mode 100644 index 0000000..ed9f6a7 --- /dev/null +++ b/translation-demo/service/pyproject.toml @@ -0,0 +1,30 @@ +[project] +name = "moq-live-translation" +version = "0.1.0" +description = "Lazy AI MoQ audio translation service" +readme = "README.md" +authors = [ + { name = "Jakub Perżyło", email = "perzylo.jakub@gmail.com" } +] +requires-python = ">=3.11" +dependencies = [ + "av>=17.0.1", + "fishjam-server-sdk>=0.28.1", + "moq-net>=0.1.0", + "websockets>=15.0", +] + +[tool.uv] +# fishjam-server-sdk pins the pre-release betterproto==2.0.0b6 +prerelease = "allow" + +[project.scripts] +translator = "translator.cli:main" + +[tool.uv.build-backend] +module-name = "translator" +module-root = "" + +[build-system] +requires = ["uv_build>=0.9.27,<0.10.0"] +build-backend = "uv_build" diff --git a/translation-demo/service/translator/__init__.py b/translation-demo/service/translator/__init__.py new file mode 100644 index 0000000..3251514 --- /dev/null +++ b/translation-demo/service/translator/__init__.py @@ -0,0 +1,5 @@ +"""MoQ live audio translation CLI.""" + +from .cli import main + +__all__ = ["main"] diff --git a/translation-demo/service/translator/__main__.py b/translation-demo/service/translator/__main__.py new file mode 100644 index 0000000..2f05ddc --- /dev/null +++ b/translation-demo/service/translator/__main__.py @@ -0,0 +1,5 @@ +from .cli import main + + +if __name__ == "__main__": + main() diff --git a/translation-demo/service/translator/catalog.py b/translation-demo/service/translator/catalog.py new file mode 100644 index 0000000..ba31a66 --- /dev/null +++ b/translation-demo/service/translator/catalog.py @@ -0,0 +1,147 @@ +"""Helpers for creating output audio tracks from Hang catalog entries.""" + +from __future__ import annotations + +from dataclasses import dataclass +from urllib.parse import quote + +from .moq_compat import moq + + +SAMPLE_RATE_INDEX = { + 96_000: 0, + 88_200: 1, + 64_000: 2, + 48_000: 3, + 44_100: 4, + 32_000: 5, + 24_000: 6, + 22_050: 7, + 16_000: 8, + 12_000: 9, + 11_025: 10, + 8_000: 11, + 7_350: 12, +} + + +@dataclass(frozen=True) +class AudioPublication: + """A source audio track that can be represented as a publishable output track.""" + + source_name: str + audio: moq.Audio + format: str + init: bytes + + +def translation_path(source_path: str, provider_name: str) -> str: + """Return the translated broadcast path for a source broadcast.""" + provider_segment = quote(provider_name.strip(), safe="") + if not provider_segment: + raise ValueError("provider name must not be empty") + + return f"{source_path.rstrip('/')}/{provider_segment}/translation" + + +def is_translation_path(path: str) -> bool: + return "translation" in path.rstrip("/").split("/") + + +def audio_publication(source_name: str, audio: moq.Audio) -> AudioPublication | None: + """Build the publish-media arguments for a supported source audio track.""" + codec = codec_family(audio.codec) + if not 0 < audio.channel_count <= 2: + raise ValueError(f"unsupported translation channel count: {audio.channel_count}") + + if codec == "opus": + return AudioPublication( + source_name=source_name, + audio=audio, + format="opus", + init=opus_head(audio.sample_rate, audio.channel_count), + ) + + if codec == "aac": + return AudioPublication( + source_name=source_name, + audio=audio, + format="aac", + init=audio_specific_config( + profile=2, + sample_rate=audio.sample_rate, + channel_count=audio.channel_count, + ), + ) + + return None + + +def codec_family(codec: str) -> str: + codec = codec.lower() + if codec == "opus": + return "opus" + if codec == "aac" or codec.startswith("mp4a.40."): + return "aac" + return codec + + +def opus_head(sample_rate: int, channel_count: int) -> bytes: + if not 0 < channel_count <= 255: + raise ValueError(f"unsupported Opus channel count: {channel_count}") + if not 0 < sample_rate <= 0xFFFFFFFF: + raise ValueError(f"unsupported Opus sample rate: {sample_rate}") + + return b"".join( + [ + b"OpusHead", + bytes([1, channel_count]), + (0).to_bytes(2, "little"), + sample_rate.to_bytes(4, "little"), + (0).to_bytes(2, "little"), + bytes([0]), + ] + ) + + +def aac_init(audio: moq.Audio) -> bytes: + if audio.description: + return bytes(audio.description) + + return audio_specific_config( + profile=aac_profile(audio.codec), + sample_rate=audio.sample_rate, + channel_count=audio.channel_count, + ) + + +def aac_profile(codec: str) -> int: + codec = codec.lower() + if codec.startswith("mp4a.40."): + try: + profile = int(codec.removeprefix("mp4a.40.")) + except ValueError as exc: + raise ValueError(f"unsupported AAC codec string: {codec}") from exc + else: + profile = 2 + + if not 0 < profile < 31: + raise ValueError(f"unsupported AAC profile: {profile}") + return profile + + +def audio_specific_config(profile: int, sample_rate: int, channel_count: int) -> bytes: + if not 0 < channel_count <= 7: + raise ValueError(f"unsupported AAC channel count: {channel_count}") + + frequency_index = SAMPLE_RATE_INDEX.get(sample_rate, 15) + first = (profile << 3) | (frequency_index >> 1) + second = ((frequency_index & 1) << 7) | (channel_count << 3) + config = bytes([first, second]) + + if frequency_index == 15: + if not 0 < sample_rate <= 0xFFFFFF: + raise ValueError(f"unsupported AAC sample rate: {sample_rate}") + config += sample_rate.to_bytes(3, "big") + + return config diff --git a/translation-demo/service/translator/cli.py b/translation-demo/service/translator/cli.py new file mode 100644 index 0000000..63d71a5 --- /dev/null +++ b/translation-demo/service/translator/cli.py @@ -0,0 +1,205 @@ +"""Command-line entrypoint for the MoQ translation service.""" + +from __future__ import annotations + +import argparse +import asyncio +import logging +import os + +from .providers.google import ( + GOOGLE_DEFAULT_API_VERSION, + GOOGLE_DEFAULT_MODEL, + GoogleTranslationProvider, +) +from .service import TranslationSpec, run, run_with_fishjam, supported_target_languages + + +PROVIDER_CHOICES = ("google",) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Announce dynamic AI translations for MoQ audio broadcasts." + ) + parser.add_argument( + "--url", + help="MoQ relay URL for direct, unauthenticated connections, " + "for example https://relay.quic.video", + ) + parser.add_argument( + "--fishjam-id", + default=os.environ.get("FISHJAM_ID"), + help="Fishjam id (or full Fishjam URL) used to fetch a MoQ token " + "(defaults to env FISHJAM_ID)", + ) + parser.add_argument( + "--fishjam-management-token", + default=os.environ.get("FISHJAM_MANAGEMENT_TOKEN"), + help="Fishjam management token used to fetch a MoQ token " + "(defaults to env FISHJAM_MANAGEMENT_TOKEN)", + ) + parser.add_argument( + "--token-ttl", + type=float, + default=3600.0, + help="MoQ token lifetime in seconds; the service reconnects with a " + "fresh token before it expires", + ) + parser.add_argument( + "--prefix", default="", help="only watch broadcasts under this prefix" + ) + parser.add_argument( + "--providers", + default=("google",), + type=parse_providers, + help="comma-separated dynamic translation providers to announce", + ) + parser.add_argument( + "--provider", + dest="providers", + type=parse_providers, + help=argparse.SUPPRESS, + ) + parser.add_argument( + "--google-model", + default=GOOGLE_DEFAULT_MODEL, + help="Google Gemini Live translation model", + ) + parser.add_argument( + "--google-api-version", + default=GOOGLE_DEFAULT_API_VERSION, + help="Google Gemini Live API version", + ) + parser.add_argument( + "--google-echo-target-language", + dest="google_echo_target_language", + action="store_true", + default=True, + help="allow Google to speak input that is already in the target language", + ) + parser.add_argument( + "--no-google-echo-target-language", + dest="google_echo_target_language", + action="store_false", + help="do not let Google speak input that is already in the target language", + ) + parser.add_argument( + "--max-latency-ms", + type=int, + default=1_000, + help="maximum source media buffering latency in milliseconds", + ) + parser.add_argument( + "--no-tls-verify", + dest="tls_verify", + action="store_false", + default=True, + help="disable TLS verification for local testing", + ) + parser.add_argument( + "--log-level", + default="INFO", + choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], + help="logging verbosity", + ) + return parser.parse_args() + + +def configure_logging(level: str) -> None: + app_level = getattr(logging, level) + logging.basicConfig( + level=app_level, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + logging.getLogger("websockets").setLevel(max(app_level, logging.INFO)) + + +def main() -> None: + args = parse_args() + configure_logging(args.log_level) + translations = build_translations(args) + + if args.fishjam_id and args.fishjam_management_token: + coroutine = run_with_fishjam( + fishjam_id=args.fishjam_id, + management_token=args.fishjam_management_token, + prefix=args.prefix, + token_ttl=args.token_ttl, + tls_verify=args.tls_verify, + max_latency_ms=args.max_latency_ms, + translations=translations, + ) + elif args.url: + coroutine = run( + url=args.url, + prefix=args.prefix, + tls_verify=args.tls_verify, + max_latency_ms=args.max_latency_ms, + translations=translations, + ) + else: + raise SystemExit( + "either --fishjam-id and --fishjam-management-token (or env " + "FISHJAM_ID/FISHJAM_MANAGEMENT_TOKEN) or --url is required" + ) + + try: + asyncio.run(coroutine) + except KeyboardInterrupt: + pass + + +def parse_providers(value: str) -> tuple[str, ...]: + providers = tuple( + provider.strip().lower() + for provider in value.split(",") + if provider.strip() + ) + if not providers: + raise argparse.ArgumentTypeError("at least one provider is required") + + invalid = [provider for provider in providers if provider not in PROVIDER_CHOICES] + if invalid: + valid = ", ".join(PROVIDER_CHOICES) + raise argparse.ArgumentTypeError( + f"unsupported provider(s): {', '.join(invalid)}; choose from {valid}" + ) + + duplicates = sorted( + {provider for provider in providers if providers.count(provider) > 1} + ) + if duplicates: + raise argparse.ArgumentTypeError(f"duplicate provider(s): {', '.join(duplicates)}") + + return providers + + +def build_translations(args: argparse.Namespace) -> list[TranslationSpec]: + providers = { + provider_name: build_provider(provider_name, args) + for provider_name in args.providers + } + return [ + TranslationSpec( + provider_name=provider_name, + target_language=target_language, + provider=providers[provider_name], + ) + for provider_name in args.providers + for target_language in supported_target_languages(providers[provider_name]) + ] + + +def build_provider(provider_name: str, args: argparse.Namespace): + if provider_name == "google": + return GoogleTranslationProvider( + model=args.google_model, + api_version=args.google_api_version, + echo_target_language=args.google_echo_target_language, + ) + raise ValueError(f"unsupported provider: {provider_name}") + + +if __name__ == "__main__": + main() diff --git a/translation-demo/service/translator/fishjam.py b/translation-demo/service/translator/fishjam.py new file mode 100644 index 0000000..551aebf --- /dev/null +++ b/translation-demo/service/translator/fishjam.py @@ -0,0 +1,23 @@ +"""Fishjam MoQ access token acquisition.""" + +from __future__ import annotations + +import asyncio + +from fishjam import FishjamClient, MoqAccess + + +async def fetch_moq_access( + fishjam_id: str, + management_token: str, + *, + publish_path: str = "", + subscribe_path: str = "", +) -> MoqAccess: + """Request a MoQ relay token granting publish/subscribe on the given path prefixes.""" + client = FishjamClient(fishjam_id=fishjam_id, management_token=management_token) + return await asyncio.to_thread( + client.create_moq_access, + publish_path=publish_path, + subscribe_path=subscribe_path, + ) diff --git a/translation-demo/service/translator/media.py b/translation-demo/service/translator/media.py new file mode 100644 index 0000000..d42c3d7 --- /dev/null +++ b/translation-demo/service/translator/media.py @@ -0,0 +1,620 @@ +"""Provider-neutral audio decoding, resampling, and encoding.""" + +from __future__ import annotations + +import logging +import time +from collections import deque +from dataclasses import dataclass +from fractions import Fraction +from typing import Deque + +import av +from av.audio.fifo import AudioFifo +from av.audio.frame import AudioFrame +from av.audio.resampler import AudioResampler + +from .catalog import aac_init, codec_family, opus_head +from .moq_compat import moq +from .providers import PcmChunk, PcmFormat +from .stats import SessionStats + + +LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class EncodedFrame: + payload: bytes + timestamp_us: int + + +class AudioTranslationPipeline: + """Decode source media frames and encode provider PCM output.""" + + def __init__( + self, + audio: moq.Audio, + *, + provider_input: PcmFormat, + provider_output: PcmFormat, + stats: SessionStats | None = None, + ) -> None: + self.provider_output = provider_output + self.stats = stats or SessionStats() + self.decoder = AudioDecoder(audio) + self.input_resampler = PcmFrameResampler(provider_input) + self.encoder = AudioEncoder(audio) + self.output_resampler = AudioFrameResampler( + format_name=self.encoder.sample_format, + sample_rate=self.encoder.sample_rate, + channels=self.encoder.channels, + ) + self._last_source_frame_timestamp_us: int | None = None + self._last_decoded_end_us: int | None = None + self._last_provider_input_end_us: int | None = None + self._last_provider_output_end_us: int | None = None + self._last_output_resampled_end_us: int | None = None + self._last_encoded_timestamp_us: int | None = None + + def decode_source(self, frame: moq.Frame) -> list[PcmChunk]: + start_ns = time.perf_counter_ns() + payload = bytes(frame.payload) + timestamp_us = int(frame.timestamp_us) + try: + _log_timestamp_delta( + "source media frame", + timestamp_us, + self._last_source_frame_timestamp_us, + payload_bytes=len(payload), + ) + self._last_source_frame_timestamp_us = timestamp_us + + chunks: list[PcmChunk] = [] + for decoded in self.decoder.decode(payload, timestamp_us): + # _log_audio_frame_interval( + # "decoded source audio", + # decoded, + # self._last_decoded_end_us, + # ) + self._last_decoded_end_us = _audio_frame_end_us(decoded) + for chunk in self.input_resampler.resample(decoded): + # _log_pcm_chunk_interval( + # "provider input pcm", + # chunk, + # self._last_provider_input_end_us, + # ) + self._last_provider_input_end_us = _pcm_chunk_end_us(chunk) + chunks.append(chunk) + return chunks + finally: + self.stats.record_decode_timing(_elapsed_us(start_ns)) + + def encode_translation(self, chunk: PcmChunk) -> list[EncodedFrame]: + start_ns = time.perf_counter_ns() + try: + if chunk.format != self.provider_output: + raise ValueError( + f"expected provider output {self.provider_output}, got {chunk.format}" + ) + _log_pcm_chunk_interval( + "provider output pcm", + chunk, + self._last_provider_output_end_us, + ) + self._last_provider_output_end_us = _pcm_chunk_end_us(chunk) + frames = self.output_resampler.resample(pcm_chunk_to_frame(chunk)) + return self._encode_output_frames(frames) + finally: + self.stats.record_encode_timing(_elapsed_us(start_ns)) + + def finish(self) -> list[EncodedFrame]: + start_ns = time.perf_counter_ns() + try: + encoded: list[EncodedFrame] = [] + encoded.extend(self._encode_output_frames(self.output_resampler.finish())) + final_frames = self.encoder.finish() + self._log_encoded_frames(final_frames) + encoded.extend(final_frames) + return encoded + finally: + self.stats.record_encode_timing(_elapsed_us(start_ns)) + + def _encode_output_frames(self, frames: list[AudioFrame]) -> list[EncodedFrame]: + for frame in frames: + # _log_audio_frame_interval( + # "output resampled audio", + # frame, + # self._last_output_resampled_end_us, + # ) + self._last_output_resampled_end_us = _audio_frame_end_us(frame) + encoded = self.encoder.encode(frames) + self._log_encoded_frames(encoded) + return encoded + + def _log_encoded_frames(self, frames: list[EncodedFrame]) -> None: + for frame in frames: + # _log_timestamp_delta( + # "encoded output frame", + # frame.timestamp_us, + # self._last_encoded_timestamp_us, + # payload_bytes=len(frame.payload), + # ) + self._last_encoded_timestamp_us = frame.timestamp_us + + +class AudioDecoder: + def __init__(self, audio: moq.Audio) -> None: + self.audio = audio + self.family = codec_family(audio.codec) + self.context = av.CodecContext.create(self.family, "r") + self.context.sample_rate = int(audio.sample_rate) + self.context.layout = layout_name(int(audio.channel_count)) + + if self.family == "aac": + self.context.extradata = aac_init(audio) + elif self.family == "opus": + self.context.extradata = opus_head( + int(audio.sample_rate), int(audio.channel_count) + ) + + self.context.open() + + def decode(self, payload: bytes, timestamp_us: int) -> list[AudioFrame]: + packet = av.Packet(payload) + frames = self.context.decode(packet) + offset_samples = 0 + + for frame in frames: + sample_rate = int(frame.sample_rate or self.audio.sample_rate) + expected_pts = timestamp_us * sample_rate // 1_000_000 + offset_samples + expected_timestamp_us = expected_pts * 1_000_000 // sample_rate + if frame.pts is not None: + assert frame.time_base is not None, ( + "decoded frame has a PTS but no time base to compare against " + "the MoQ frame timestamp" + ) + frame_timestamp_us = int(frame.pts * frame.time_base * 1_000_000) + assert frame_timestamp_us == expected_timestamp_us, ( + "decoded frame timestamp does not match MoQ frame timestamp: " + f"decoded={frame_timestamp_us} expected={expected_timestamp_us}" + ) + + frame.pts = expected_pts + frame.time_base = Fraction(1, sample_rate) + offset_samples += frame.samples + + return frames + + +class PcmFrameResampler: + def __init__(self, output_format: PcmFormat) -> None: + self.output_format = output_format + self.resampler = AudioResampler( + format="s16", layout=output_format.layout, rate=output_format.sample_rate + ) + + def resample(self, frame: AudioFrame) -> list[PcmChunk]: + chunks: list[PcmChunk] = [] + for pcm_frame in self.resampler.resample(frame): + data = pcm_frame_to_bytes(pcm_frame, self.output_format) + timestamp_us = frame_timestamp_us(pcm_frame) + chunks.append( + PcmChunk( + data=data, format=self.output_format, timestamp_us=timestamp_us + ) + ) + return chunks + + +class AudioFrameResampler: + def __init__(self, *, format_name: str, sample_rate: int, channels: int) -> None: + self.format_name = format_name + self.sample_rate = sample_rate + self.channels = channels + self.resampler = self._create_resampler() + self._segment_base_timestamp_us: int | None = None + self._segment_input_end_us: int | None = None + self._input_sample_rate: int | None = None + self._next_input_local_sample = 0 + self._continuity_tolerance_us = max(1, 2_000_000 // sample_rate) + + def resample(self, frame: AudioFrame) -> list[AudioFrame]: + input_sample_rate = int(frame.sample_rate) + frame_start_us = frame_timestamp_us(frame) + output: list[AudioFrame] = [] + + if ( + self._segment_base_timestamp_us is None + or self._input_sample_rate != input_sample_rate + ): + if self._segment_base_timestamp_us is not None: + output.extend(self.finish()) + self._reset_segment() + self._start_segment(frame_start_us, input_sample_rate) + else: + assert self._segment_input_end_us is not None + gap_us = frame_start_us - self._segment_input_end_us + if gap_us > self._continuity_tolerance_us: + output.extend(self.finish()) + LOGGER.debug( + "resetting audio resampler segment gap_ms=%.1f previous_end_ms=%.1f next_audio_ms=%.1f", + gap_us / 1_000, + self._segment_input_end_us / 1_000, + frame_start_us / 1_000, + ) + self._reset_segment() + self._start_segment(frame_start_us, input_sample_rate) + + frame.pts = self._next_input_local_sample + frame.time_base = Fraction(1, input_sample_rate) + output.extend(self._retimestamp_output(self.resampler.resample(frame))) + + self._next_input_local_sample += frame.samples + assert self._segment_base_timestamp_us is not None + self._segment_input_end_us = ( + self._segment_base_timestamp_us + + self._next_input_local_sample * 1_000_000 // input_sample_rate + ) + return output + + def finish(self) -> list[AudioFrame]: + return self._retimestamp_output(self.resampler.resample(None)) + + def _create_resampler(self) -> AudioResampler: + return AudioResampler( + format=self.format_name, + layout=layout_name(self.channels), + rate=self.sample_rate, + ) + + def _start_segment(self, timestamp_us: int, input_sample_rate: int) -> None: + self._segment_base_timestamp_us = timestamp_us + self._segment_input_end_us = timestamp_us + self._input_sample_rate = input_sample_rate + self._next_input_local_sample = 0 + + def _reset_segment(self) -> None: + self.resampler = self._create_resampler() + self._segment_base_timestamp_us = None + self._segment_input_end_us = None + self._input_sample_rate = None + self._next_input_local_sample = 0 + + def _retimestamp_output(self, frames: list[AudioFrame]) -> list[AudioFrame]: + if self._segment_base_timestamp_us is None: + return frames + + for frame in frames: + sample_rate = int(frame.sample_rate or self.sample_rate) + local_sample = int(frame.pts or 0) + base_sample = self._segment_base_timestamp_us * sample_rate // 1_000_000 + frame.pts = base_sample + local_sample + frame.time_base = Fraction(1, sample_rate) + return frames + + +class AudioEncoder: + def __init__(self, audio: moq.Audio) -> None: + self.family = codec_family(audio.codec) + self.sample_rate = int(audio.sample_rate) + self.channels = int(audio.channel_count) + self.sample_format = encoder_sample_format(self.family) + self.bit_rate = int( + audio.bitrate or default_bit_rate(self.family, self.channels) + ) + self.context = self._create_context() + self.fifo = AudioFifo() + self.frame_size = self.context.frame_size or max(1, self.sample_rate // 50) + self._queued_timestamps: Deque[int] = deque() + self._segment_base_timestamp_us: int | None = None + self._segment_end_timestamp_us: int | None = None + self._next_local_sample = 0 + self._continuity_tolerance_us = max(1, 2_000_000 // self.sample_rate) + + def encode(self, frames: list[AudioFrame]) -> list[EncodedFrame]: + encoded: list[EncodedFrame] = [] + for frame in frames: + encoded.extend(self._write_frame(frame)) + return encoded + + def finish(self) -> list[EncodedFrame]: + return self._finish_segment() + + def _create_context(self) -> av.CodecContext: + context = av.CodecContext.create(encoder_name(self.family), "w") + context.sample_rate = self.sample_rate + context.layout = layout_name(self.channels) + context.format = self.sample_format + context.bit_rate = self.bit_rate + context.time_base = Fraction(1, self.sample_rate) + context.open() + return context + + def _write_frame(self, frame: AudioFrame) -> list[EncodedFrame]: + if int(frame.sample_rate) != self.sample_rate: + raise ValueError( + f"expected {self.sample_rate} Hz encoder frame, got {frame.sample_rate}" + ) + + encoded: list[EncodedFrame] = [] + frame_start_us = frame_timestamp_us(frame) + + if self._segment_base_timestamp_us is None: + self._start_segment(frame_start_us) + else: + assert self._segment_end_timestamp_us is not None + gap_us = frame_start_us - self._segment_end_timestamp_us + if gap_us > self._continuity_tolerance_us: + LOGGER.debug( + "resetting audio encoder segment gap_ms=%.1f previous_end_ms=%.1f next_audio_ms=%.1f", + gap_us / 1_000, + self._segment_end_timestamp_us / 1_000, + frame_start_us / 1_000, + ) + encoded.extend(self._finish_segment()) + self._reset_segment() + self._start_segment(frame_start_us) + + frame.pts = self._next_local_sample + frame.time_base = Fraction(1, self.sample_rate) + self.fifo.write(frame) + self._next_local_sample += frame.samples + + assert self._segment_base_timestamp_us is not None + self._segment_end_timestamp_us = ( + self._segment_base_timestamp_us + + self._next_local_sample * 1_000_000 // self.sample_rate + ) + + encoded.extend(self._drain_fifo(partial=False)) + return encoded + + def _start_segment(self, timestamp_us: int) -> None: + self._segment_base_timestamp_us = timestamp_us + self._segment_end_timestamp_us = timestamp_us + self._next_local_sample = 0 + + def _reset_segment(self) -> None: + self.context = self._create_context() + self.fifo = AudioFifo() + self._queued_timestamps.clear() + self._segment_base_timestamp_us = None + self._segment_end_timestamp_us = None + self._next_local_sample = 0 + + def _finish_segment(self) -> list[EncodedFrame]: + encoded = self._drain_fifo(partial=True) + encoded.extend(self._packets_to_frames(self.context.encode(None))) + self._queued_timestamps.clear() + return encoded + + def _drain_fifo(self, *, partial: bool) -> list[EncodedFrame]: + encoded: list[EncodedFrame] = [] + while self.fifo.samples >= self.frame_size: + frame = self.fifo.read(self.frame_size) + encoded.extend(self._encode_frame(frame)) + + if partial and self.fifo.samples: + frame = self.fifo.read(self.fifo.samples, partial=True) + if frame is not None: + encoded.extend(self._encode_frame(frame)) + + return encoded + + def _encode_frame(self, frame: AudioFrame) -> list[EncodedFrame]: + timestamp_us = self._frame_timestamp_us(frame) + self._queued_timestamps.append(timestamp_us) + return self._packets_to_frames(self.context.encode(frame)) + + def _packets_to_frames(self, packets) -> list[EncodedFrame]: + encoded: list[EncodedFrame] = [] + for packet in packets: + timestamp_us = ( + self._queued_timestamps.popleft() + if self._queued_timestamps + else self._fallback_timestamp_us() + ) + encoded.append( + EncodedFrame(payload=bytes(packet), timestamp_us=timestamp_us) + ) + return encoded + + def _frame_timestamp_us(self, frame: AudioFrame) -> int: + if self._segment_base_timestamp_us is None: + return frame_timestamp_us(frame) + local_sample = int(frame.pts or 0) + return ( + self._segment_base_timestamp_us + + local_sample * 1_000_000 // self.sample_rate + ) + + def _fallback_timestamp_us(self) -> int: + if self._segment_end_timestamp_us is not None: + return self._segment_end_timestamp_us + if self._segment_base_timestamp_us is not None: + return self._segment_base_timestamp_us + return 0 + + +def _log_timestamp_delta( + stage: str, + timestamp_us: int, + last_timestamp_us: int | None, + *, + payload_bytes: int, +) -> None: + pass + # if LOGGER.isEnabledFor(logging.DEBUG): + # delta_us = ( + # timestamp_us - last_timestamp_us if last_timestamp_us is not None else None + # ) + # LOGGER.debug( + # "audio timing stage=%s timestamp_ms=%.1f delta_ms=%s bytes=%d", + # stage, + # timestamp_us / 1_000, + # _format_ms(delta_us), + # payload_bytes, + # ) + + +def _log_pcm_chunk_interval( + stage: str, chunk: PcmChunk, last_end_us: int | None +) -> None: + duration_us = chunk.samples * 1_000_000 // chunk.format.sample_rate + _log_audio_interval( + stage, + timestamp_us=chunk.timestamp_us, + duration_us=duration_us, + last_end_us=last_end_us, + samples=chunk.samples, + sample_rate=chunk.format.sample_rate, + layout=chunk.format.layout, + payload_bytes=len(chunk.data), + ) + + +def _log_audio_frame_interval( + stage: str, frame: AudioFrame, last_end_us: int | None +) -> None: + sample_rate = int(frame.sample_rate or 0) + duration_us = frame.samples * 1_000_000 // sample_rate if sample_rate else 0 + _log_audio_interval( + stage, + timestamp_us=frame_timestamp_us(frame), + duration_us=duration_us, + last_end_us=last_end_us, + samples=frame.samples, + sample_rate=sample_rate, + layout=frame.layout.name, + payload_bytes=_audio_frame_payload_bytes(frame) + if LOGGER.isEnabledFor(logging.DEBUG) + else 0, + ) + + +def _log_audio_interval( + stage: str, + *, + timestamp_us: int, + duration_us: int, + last_end_us: int | None, + samples: int, + sample_rate: int, + layout: str, + payload_bytes: int, +) -> None: + end_us = timestamp_us + duration_us + if LOGGER.isEnabledFor(logging.DEBUG): + gap_us = timestamp_us - last_end_us if last_end_us is not None else None + LOGGER.debug( + ( + "audio timing stage=%s timestamp_ms=%.1f duration_ms=%.1f gap_ms=%s " + "end_ms=%.1f samples=%d sample_rate=%d layout=%s bytes=%d" + ), + stage, + timestamp_us / 1_000, + duration_us / 1_000, + _format_ms(gap_us), + end_us / 1_000, + samples, + sample_rate, + layout, + payload_bytes, + ) + + +def _pcm_chunk_end_us(chunk: PcmChunk) -> int: + duration_us = chunk.samples * 1_000_000 // chunk.format.sample_rate + return chunk.timestamp_us + duration_us + + +def _elapsed_us(start_ns: int) -> int: + return (time.perf_counter_ns() - start_ns) // 1_000 + + +def _audio_frame_end_us(frame: AudioFrame) -> int: + timestamp_us = frame_timestamp_us(frame) + sample_rate = int(frame.sample_rate or 0) + if not sample_rate: + return timestamp_us + duration_us = frame.samples * 1_000_000 // sample_rate + return timestamp_us + duration_us + + +def _format_ms(value_us: int | None) -> str: + if value_us is None: + return "n/a" + return f"{value_us / 1_000:.1f}" + + +def _audio_frame_payload_bytes(frame: AudioFrame) -> int: + total = 0 + for plane in frame.planes: + buffer_size = getattr(plane, "buffer_size", None) + total += int(buffer_size) if buffer_size is not None else len(bytes(plane)) + return total + + +def pcm_chunk_to_frame(chunk: PcmChunk) -> AudioFrame: + samples = chunk.samples + frame = av.AudioFrame(format="s16", layout=chunk.format.layout, samples=samples) + frame.sample_rate = chunk.format.sample_rate + frame.time_base = Fraction(1, chunk.format.sample_rate) + frame.pts = chunk.timestamp_us * chunk.format.sample_rate // 1_000_000 + frame.planes[0].update(chunk.data) + return frame + + +def pcm_frame_to_bytes(frame: AudioFrame, expected_format: PcmFormat) -> bytes: + if frame.format.name != "s16": + raise ValueError(f"expected s16 PCM frame, got {frame.format.name}") + if frame.layout.name != expected_format.layout: + raise ValueError( + f"expected {expected_format.layout} PCM frame, got {frame.layout.name}" + ) + if int(frame.sample_rate) != expected_format.sample_rate: + raise ValueError( + f"expected {expected_format.sample_rate} Hz PCM frame, got {frame.sample_rate}" + ) + + expected_bytes = frame.samples * expected_format.bytes_per_frame + return bytes(frame.planes[0])[:expected_bytes] + + +def frame_timestamp_us(frame: AudioFrame) -> int: + if frame.pts is None or frame.time_base is None: + return 0 + return int(frame.pts * frame.time_base * 1_000_000) + + +def layout_name(channels: int) -> str: + if channels == 1: + return "mono" + if channels == 2: + return "stereo" + raise ValueError(f"unsupported channel count: {channels}") + + +def encoder_name(family: str) -> str: + if family == "opus": + return "libopus" + if family == "aac": + return "aac" + raise ValueError(f"unsupported audio codec: {family}") + + +def encoder_sample_format(family: str) -> str: + if family == "opus": + return "s16" + if family == "aac": + return "fltp" + raise ValueError(f"unsupported audio codec: {family}") + + +def default_bit_rate(family: str, channels: int) -> int: + if family == "opus": + return 64_000 * channels + if family == "aac": + return 96_000 * channels + raise ValueError(f"unsupported audio codec: {family}") diff --git a/translation-demo/service/translator/moq_compat.py b/translation-demo/service/translator/moq_compat.py new file mode 100644 index 0000000..ca8074a --- /dev/null +++ b/translation-demo/service/translator/moq_compat.py @@ -0,0 +1,75 @@ +"""Compatibility helpers for MoQ Python APIs used by the service.""" + +from __future__ import annotations + +try: + import moq_net as moq +except ModuleNotFoundError: + import moq + + +class _BroadcastDynamic: + """Wrapper for dynamic broadcast requests. + + Older `moq-net` wrappers do not expose this API, while newer `moq-ffi` + builds do. Prefer the `moq` wrapper when it is installed; this fallback + only adapts an older wrapper around a newer raw FFI install. + """ + + def __init__(self, inner) -> None: + self._inner = inner + + def __aiter__(self): + return self + + async def __anext__(self): + track = await self.requested_track() + if track is None: + raise StopAsyncIteration + return track + + async def requested_track(self): + track = await self._inner.requested_track() + if track is None: + return None + return moq.TrackProducer(track) + + def cancel(self) -> None: + self._inner.cancel() + + +def _dynamic(self): + try: + return _BroadcastDynamic(self._inner.dynamic()) + except AttributeError as exc: + raise RuntimeError( + "installed MoQ Python packages do not support dynamic broadcasts; " + "install the newer `moq`/`moq-rs` wrapper with moq-ffi >= 0.2.16" + ) from exc + + +def _publish_requested_media(self, track, format: str, init: bytes): + publish_media_on_track = getattr(self, "publish_media_on_track", None) + if publish_media_on_track is not None: + return publish_media_on_track(track, format, init) + + try: + publish = self._inner.publish_requested_media + except AttributeError as exc: + try: + publish = self._inner.publish_media_on_track + except AttributeError: + raise RuntimeError( + "installed MoQ Python packages do not support requested media tracks; " + "install the newer `moq`/`moq-rs` wrapper with moq-ffi >= 0.2.16" + ) from exc + + inner = publish(track._inner, format, init) + return moq.MediaProducer(inner) + + +if not hasattr(moq.BroadcastProducer, "dynamic"): + moq.BroadcastProducer.dynamic = _dynamic + +if not hasattr(moq.BroadcastProducer, "publish_requested_media"): + moq.BroadcastProducer.publish_requested_media = _publish_requested_media diff --git a/translation-demo/service/translator/providers/__init__.py b/translation-demo/service/translator/providers/__init__.py new file mode 100644 index 0000000..c6dac38 --- /dev/null +++ b/translation-demo/service/translator/providers/__init__.py @@ -0,0 +1,21 @@ +"""Translation provider implementations.""" + +from .base import ( + PcmChunk, + PcmFormat, + ProviderEvent, + TranscriptEvent, + TranslationContext, + TranslationProvider, + TranslationSession, +) + +__all__ = [ + "PcmChunk", + "PcmFormat", + "ProviderEvent", + "TranscriptEvent", + "TranslationContext", + "TranslationProvider", + "TranslationSession", +] diff --git a/translation-demo/service/translator/providers/base.py b/translation-demo/service/translator/providers/base.py new file mode 100644 index 0000000..b679812 --- /dev/null +++ b/translation-demo/service/translator/providers/base.py @@ -0,0 +1,84 @@ +"""Provider-neutral translation contracts.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Protocol + +from ..stats import SessionStats + + +@dataclass(frozen=True) +class PcmFormat: + sample_rate: int + channels: int + sample_width: int = 2 + + @property + def bytes_per_frame(self) -> int: + return self.channels * self.sample_width + + @property + def layout(self) -> str: + if self.channels == 1: + return "mono" + if self.channels == 2: + return "stereo" + raise ValueError(f"unsupported channel count: {self.channels}") + + def samples_for(self, data: bytes) -> int: + if len(data) % self.bytes_per_frame != 0: + raise ValueError("PCM payload is not aligned to whole audio samples") + return len(data) // self.bytes_per_frame + + +@dataclass(frozen=True) +class PcmChunk: + data: bytes + format: PcmFormat + # Presentation timestamp in microseconds. Provider output chunks may be sparse + # when silence should be preserved between translated speech bursts. + timestamp_us: int + + @property + def samples(self) -> int: + return self.format.samples_for(self.data) + + +@dataclass(frozen=True) +class TranscriptEvent: + kind: str + delta: str + + +@dataclass(frozen=True) +class ProviderEvent: + audio: PcmChunk | None = None + transcript: TranscriptEvent | None = None + + +@dataclass(frozen=True) +class TranslationContext: + source_path: str + source_track: str + target_language: str + stats: SessionStats = field(default_factory=SessionStats) + + +class TranslationSession(Protocol): + input_format: PcmFormat + output_format: PcmFormat + + async def send_audio(self, chunk: PcmChunk) -> None: + """Send normalized PCM audio into the provider.""" + + async def receive(self) -> ProviderEvent | None: + """Receive translated PCM audio or transcript events.""" + + async def close(self) -> None: + """Close the provider session.""" + + +class TranslationProvider(Protocol): + async def start(self, context: TranslationContext) -> TranslationSession: + """Start one provider session for one actively subscribed source track.""" diff --git a/translation-demo/service/translator/providers/google.py b/translation-demo/service/translator/providers/google.py new file mode 100644 index 0000000..fbdbc1a --- /dev/null +++ b/translation-demo/service/translator/providers/google.py @@ -0,0 +1,545 @@ +"""Google Gemini Live translation provider.""" + +from __future__ import annotations + +import base64 +import json +import logging +import os +import time +from collections import deque +from dataclasses import dataclass +from typing import Any, ClassVar + +from websockets.asyncio.client import ClientConnection, connect +from websockets.exceptions import ConnectionClosed + +from .base import ( + PcmChunk, + PcmFormat, + ProviderEvent, + TranscriptEvent, + TranslationContext, + TranslationSession, +) + + +LOGGER = logging.getLogger(__name__) +GOOGLE_INPUT_PCM = PcmFormat(sample_rate=16_000, channels=1) +GOOGLE_OUTPUT_PCM = PcmFormat(sample_rate=24_000, channels=1) +GOOGLE_OUTPUT_GAP_THRESHOLD_US = 500_000 +GOOGLE_DEFAULT_MODEL = "models/gemini-3.5-live-translate-preview" +GOOGLE_DEFAULT_API_VERSION = "v1beta" +# Source: Gemini Live Translate supported languages table. +GOOGLE_SUPPORTED_TARGET_LANGUAGES = ( + "af", + "ak", + "sq", + "am", + "ar", + "hy", + "az", + "eu", + "be", + "bn", + "bg", + "my", + "ca", + "zh-Hans", + "zh-Hant", + "hr", + "cs", + "da", + "nl", + "en", + "et", + "fil", + "fi", + "fr", + "gl", + "ka", + "de", + "el", + "gu", + "ha", + "he", + "hi", + "hu", + "is", + "id", + "it", + "ja", + "jv", + "kn", + "kk", + "km", + "rw", + "ko", + "lo", + "lv", + "lt", + "mk", + "ms", + "ml", + "mr", + "mn", + "ne", + "no", + "nb", + "fa", + "pl", + "pt-BR", + "pt-PT", + "pa", + "ro", + "ru", + "sr", + "sd", + "si", + "sk", + "sl", + "es", + "su", + "sw", + "sv", + "ta", + "te", + "th", + "tr", + "uk", + "ur", + "uz", + "vi", + "zu", +) + + +class GoogleConfigurationError(RuntimeError): + """Raised when the Google provider is missing required configuration.""" + + +@dataclass(frozen=True) +class GoogleTranslationProvider: + name: ClassVar[str] = "google" + supported_target_languages: ClassVar[tuple[str, ...]] = GOOGLE_SUPPORTED_TARGET_LANGUAGES + + model: str = GOOGLE_DEFAULT_MODEL + api_version: str = GOOGLE_DEFAULT_API_VERSION + echo_target_language: bool = True + logger: logging.Logger = LOGGER + + async def start(self, context: TranslationContext) -> TranslationSession: + api_key = self._api_key() + websocket = await connect( + google_live_url(self.api_version), + additional_headers={"x-goog-api-key": api_key}, + compression=None, + max_size=None, + ) + session = GoogleTranslationSession( + websocket, + context, + model=google_model_name(self.model), + echo_target_language=self.echo_target_language, + logger=self.logger, + ) + await session.configure() + return session + + def _api_key(self) -> str: + api_key = os.environ.get("GEMINI_API_KEY") + if api_key: + return api_key + + api_key = os.environ.get("GOOGLE_API_KEY") + if api_key: + self.logger.warning( + "using GOOGLE_API_KEY; prefer GEMINI_API_KEY for Google credentials" + ) + return api_key + + raise GoogleConfigurationError("GEMINI_API_KEY is required for provider=google") + + +class GoogleTranslationSession: + input_format = GOOGLE_INPUT_PCM + output_format = GOOGLE_OUTPUT_PCM + + def __init__( + self, + websocket: ClientConnection, + context: TranslationContext, + *, + model: str, + echo_target_language: bool, + logger: logging.Logger = LOGGER, + ) -> None: + self.websocket = websocket + self.context = context + self.model = model + self.echo_target_language = echo_target_language + self.logger = logger + self._pending_events: deque[ProviderEvent] = deque() + self._burst_open = False + self._output_end_us = 0 + self._first_input_timestamp_us: int | None = None + self._first_input_sent_us: int | None = None + self._first_audio_response_logged = False + self._last_output_arrival_us: int | None = None + self._last_output_duration_us = 0 + self._closed = False + + async def configure(self) -> None: + payload = json.dumps( + { + "setup": { + "model": self.model, + "generationConfig": { + "responseModalities": ["AUDIO"], + "translationConfig": { + "targetLanguageCode": self.context.target_language, + "echoTargetLanguage": True, + }, + }, + }, + } + ) + + self.logger.info("setup=%s", payload) + await self.websocket.send(payload) + + while True: + event = await self._recv_event() + if "setupComplete" in event: + self.logger.info( + "opened Google translation session source=%s source_track=%s target_language=%s model=%s", + self.context.source_path, + self.context.source_track, + self.context.target_language, + self.model, + ) + return + + self._raise_for_error(event) + self._log_unhandled_event(event, prefix="Google setup") + + async def send_audio(self, chunk: PcmChunk) -> None: + if chunk.format != self.input_format: + raise ValueError( + f"Google input must be {self.input_format}, got {chunk.format}" + ) + + if self._first_input_sent_us is None: + self._first_input_timestamp_us = chunk.timestamp_us + self._first_input_sent_us = time.monotonic_ns() // 1_000 + if self._last_output_arrival_us is None: + self._output_end_us = chunk.timestamp_us + self.logger.debug( + "starting Gemini output clock anchor_ms=%.1f source=%s source_track=%s target_language=%s", + chunk.timestamp_us / 1_000, + self.context.source_path, + self.context.source_track, + self.context.target_language, + ) + + await self.websocket.send( + json.dumps( + { + "realtimeInput": { + "audio": { + "mimeType": f"audio/pcm;rate={self.input_format.sample_rate}", + "data": base64.b64encode(chunk.data).decode("ascii"), + }, + }, + } + ) + ) + self.context.stats.record_model_input_end(_pcm_chunk_end_us(chunk)) + + async def receive(self) -> ProviderEvent | None: + while True: + if self._pending_events: + return self._pending_events.popleft() + + event = await self._recv_event() + if not event and self._closed: + return None + + self._raise_for_error(event) + self._queue_provider_events(event) + + if self._pending_events: + return self._pending_events.popleft() + + self._log_unhandled_event(event) + + async def close(self) -> None: + if self._closed: + return + self._closed = True + await self.websocket.close() + + async def _recv_event(self) -> dict[str, Any]: + try: + raw = await self.websocket.recv() + except ConnectionClosed as exc: + if self._closed: + return {} + raise RuntimeError("Google realtime connection closed") from exc + + if isinstance(raw, bytes): + raw = raw.decode("utf-8") + event = json.loads(raw) + if self.logger.isEnabledFor(logging.DEBUG): + self.logger.debug( + "Google response payload=%s", + json.dumps(_redact_audio_payload(event), sort_keys=True), + ) + return event + + def _queue_provider_events(self, event: dict[str, Any]) -> None: + server_content = event.get("serverContent") or event.get("server_content") + if not isinstance(server_content, dict): + return + + arrival_us = time.monotonic_ns() // 1_000 + audio_parts = _audio_parts(server_content) + if audio_parts: + self._log_first_audio_response_latency(arrival_us) + + input_transcript = _transcription_text( + server_content, "inputTranscription", "input_transcription" + ) + output_transcript = _transcription_text( + server_content, "outputTranscription", "output_transcription" + ) + + for data in audio_parts: + timestamp_us = self._next_output_timestamp(data, arrival_us) + self._pending_events.append( + ProviderEvent( + audio=PcmChunk( + data=data, format=self.output_format, timestamp_us=timestamp_us + ) + ) + ) + + if input_transcript: + self._pending_events.append( + ProviderEvent( + transcript=TranscriptEvent(kind="input", delta=input_transcript) + ) + ) + + if output_transcript: + self._pending_events.append( + ProviderEvent( + transcript=TranscriptEvent(kind="output", delta=output_transcript) + ) + ) + + if server_content.get("generationComplete") or server_content.get( + "turnComplete" + ): + if self._burst_open: + self.logger.debug("closing Google output burst") + self._burst_open = False + + if server_content.get("interrupted"): + self.logger.info( + "Google realtime generation interrupted source=%s source_track=%s", + self.context.source_path, + self.context.source_track, + ) + + def _raise_for_error(self, event: dict[str, Any]) -> None: + if not event: + return + if "error" in event: + raise RuntimeError(f"Google realtime error: {event['error']}") + + def _log_unhandled_event( + self, event: dict[str, Any], *, prefix: str = "Google realtime" + ) -> None: + if not event: + return + + message_type = _message_type(event) + self.logger.debug("%s event type=%s", prefix, message_type) + + def _log_first_audio_response_latency(self, arrival_us: int) -> None: + if ( + self._first_audio_response_logged + or self._first_input_sent_us is None + ): + return + + self._first_audio_response_logged = True + latency_us = max(0, arrival_us - self._first_input_sent_us) + self.context.stats.record_first_response_latency(latency_us) + input_timestamp_us = ( + self._first_input_timestamp_us + if self._first_input_timestamp_us is not None + else 0 + ) + self.logger.info( + ( + "Gemini first audio response latency_ms=%.1f " + "first_input_timestamp_ms=%.1f source=%s source_track=%s target_language=%s" + ), + latency_us / 1_000, + input_timestamp_us / 1_000, + self.context.source_path, + self.context.source_track, + self.context.target_language, + ) + + def _next_output_timestamp(self, data: bytes, arrival_us: int) -> int: + duration_us = ( + self.output_format.samples_for(data) + * 1_000_000 + // self.output_format.sample_rate + ) + previous_end_us = ( + self._output_end_us if self._last_output_arrival_us is not None else None + ) + timestamp_us = self._output_end_us + elapsed_us: int | None = None + idle_us: int | None = None + preserved_gap_us: int | None = None + + if self._last_output_arrival_us is None: + self.logger.debug( + "starting Google output audio timestamp anchor_ms=%.1f", + timestamp_us / 1_000, + ) + else: + elapsed_us = max(0, arrival_us - self._last_output_arrival_us) + idle_us = elapsed_us - self._last_output_duration_us + if idle_us > GOOGLE_OUTPUT_GAP_THRESHOLD_US: + timestamp_us += idle_us + preserved_gap_us = idle_us + + output_gap_us = ( + timestamp_us - previous_end_us if previous_end_us is not None else None + ) + output_end_us = timestamp_us + duration_us + self.logger.debug( + ( + "Google output audio delta bytes=%d duration_ms=%.1f arrival_delta_ms=%s " + "idle_gap_ms=%s output_gap_ms=%s preserved_gap_ms=%s timestamp_ms=%.1f end_ms=%.1f" + ), + len(data), + duration_us / 1_000, + _format_ms(elapsed_us), + _format_ms(idle_us), + _format_ms(output_gap_us), + _format_ms(preserved_gap_us), + timestamp_us / 1_000, + output_end_us / 1_000, + ) + self._burst_open = True + self._last_output_arrival_us = arrival_us + self._last_output_duration_us = duration_us + self._output_end_us = output_end_us + self.context.stats.record_model_output_end(output_end_us) + return timestamp_us + + +def google_live_url(api_version: str) -> str: + version = api_version.strip() + if not version: + raise ValueError("Google API version cannot be empty") + + return ( + "wss://generativelanguage.googleapis.com/ws/" + f"google.ai.generativelanguage.{version}.GenerativeService.BidiGenerateContent" + ) + + +def google_model_name(model: str) -> str: + model = model.strip() + if not model: + raise ValueError("Google model cannot be empty") + if model.startswith("models/"): + return model + return f"models/{model}" + + +def _audio_parts(server_content: dict[str, Any]) -> list[bytes]: + model_turn = server_content.get("modelTurn") or server_content.get("model_turn") + if not isinstance(model_turn, dict): + return [] + + audio: list[bytes] = [] + for part in model_turn.get("parts", []): + if not isinstance(part, dict): + continue + + inline_data = part.get("inlineData") or part.get("inline_data") + if not isinstance(inline_data, dict): + continue + + mime_type = inline_data.get("mimeType") or inline_data.get("mime_type") or "" + if mime_type and not str(mime_type).startswith("audio/"): + continue + + data = inline_data.get("data") + if data: + audio.append(base64.b64decode(data)) + + return audio + + +def _transcription_text(server_content: dict[str, Any], *keys: str) -> str: + for key in keys: + transcription = server_content.get(key) + if isinstance(transcription, dict): + text = transcription.get("text") + if text: + return str(text) + return "" + + +def _pcm_chunk_end_us(chunk: PcmChunk) -> int: + duration_us = chunk.samples * 1_000_000 // chunk.format.sample_rate + return chunk.timestamp_us + duration_us + + +def _message_type(event: dict[str, Any]) -> str: + for key in ( + "setupComplete", + "serverContent", + "toolCall", + "toolCallCancellation", + "goAway", + "sessionResumptionUpdate", + "error", + ): + if key in event: + return key + return ",".join(sorted(event)) or "empty" + + +def _redact_audio_payload(value: Any) -> Any: + if isinstance(value, dict): + redacted = {key: _redact_audio_payload(child) for key, child in value.items()} + mime_type = redacted.get("mimeType") or redacted.get("mime_type") or "" + if _is_audio_mime_type(mime_type) and isinstance(redacted.get("data"), str): + redacted["data"] = f"" + return redacted + + if isinstance(value, list): + return [_redact_audio_payload(item) for item in value] + + return value + + +def _is_audio_mime_type(mime_type: Any) -> bool: + return str(mime_type).startswith("audio/") + + +def _format_ms(value_us: int | None) -> str: + if value_us is None: + return "n/a" + return f"{value_us / 1_000:.1f}" diff --git a/translation-demo/service/translator/service.py b/translation-demo/service/translator/service.py new file mode 100644 index 0000000..f2a1d92 --- /dev/null +++ b/translation-demo/service/translator/service.py @@ -0,0 +1,1187 @@ +"""Async MoQ translation service.""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +from collections.abc import Sequence +from dataclasses import dataclass + +from .catalog import AudioPublication, audio_publication, is_translation_path, translation_path +from .fishjam import fetch_moq_access +from .moq_compat import moq +from .providers import TranslationContext, TranslationProvider +from .providers.google import GoogleTranslationProvider +from .transcript import TranscriptPublisher, parse_transcript_track_name, transcript_track_name +from .translator import TrackTranslator + + +LOGGER = logging.getLogger(__name__) +TRANSLATION_UNUSED_GRACE_SECONDS = 5.0 + + +@dataclass(frozen=True) +class TranslationSpec: + provider_name: str + target_language: str + provider: TranslationProvider + + +@dataclass(frozen=True) +class TranslationGroup: + provider_name: str + provider: TranslationProvider + languages: dict[str, str] + + +@dataclass(frozen=True) +class TranslationOutput: + group: TranslationGroup + path: str + broadcast: moq.BroadcastProducer + dynamic: object + source: AudioPublication + translator: TrackTranslator + + +class MediaFanout: + def __init__(self, *, logger: logging.Logger = LOGGER) -> None: + self.logger = logger + self.empty = asyncio.Event() + self.empty.set() + self._media: list[moq.MediaProducer] = [] + self._demanding: list[moq.MediaProducer] = [] + + @property + def has_demand(self) -> bool: + return bool(self._demanding) + + def add(self, media: moq.MediaProducer) -> None: + self.mark_active(media) + + def mark_active(self, media: moq.MediaProducer) -> None: + if not self._contains(self._media, media): + self._media.append(media) + if not self._contains(self._demanding, media): + self._demanding.append(media) + self.empty.clear() + + def mark_inactive(self, media: moq.MediaProducer) -> None: + self._demanding = [item for item in self._demanding if item is not media] + if not self._demanding: + self.empty.set() + + def write_frame(self, payload: bytes, timestamp_us: int) -> None: + for media in tuple(self._media): + try: + media.write_frame(payload, timestamp_us) + except Exception: + self.logger.debug( + "failed to write translated frame output_track=%s", + _safe_media_name(media), + exc_info=True, + ) + + def close(self) -> None: + for media in tuple(self._media): + with contextlib.suppress(Exception): + media.finish() + self._media.clear() + self._demanding.clear() + self.empty.set() + + @staticmethod + def _contains(media: list[moq.MediaProducer], candidate: moq.MediaProducer) -> bool: + return any(item is candidate for item in media) + + +@dataclass +class ActiveTranslation: + transcript: TranscriptPublisher + done: asyncio.Event + media: MediaFanout + + +class TranslationService: + def __init__( + self, + client: moq.Client, + *, + prefix: str = "", + max_latency_ms: int = 10_000, + translations: Sequence[TranslationSpec], + logger: logging.Logger = LOGGER, + ) -> None: + if not translations: + raise ValueError("at least one translation provider/language is required") + + self.client = client + self.prefix = prefix + self.max_latency_ms = max_latency_ms + self.translations = tuple(translations) + self.translation_groups = tuple(_translation_groups(self.translations)) + self.logger = logger + self._broadcast_tasks: dict[str, asyncio.Task[None]] = {} + + async def run(self) -> None: + self.logger.info("watching relay announcements prefix=%r", self.prefix) + try: + async for announcement in self.client.announced(self.prefix): + path = announcement.path + if is_translation_path(path): + self.logger.debug("skipping translation broadcast path=%s", path) + continue + + task = self._broadcast_tasks.get(path) + if task is not None and not task.done(): + self.logger.debug("broadcast is already being handled path=%s", path) + continue + + self.logger.info("new broadcast announced path=%s", path) + task = asyncio.create_task( + self._handle_broadcast(path, announcement.broadcast), + name=f"broadcast:{path}", + ) + self._broadcast_tasks[path] = task + task.add_done_callback(lambda done, broadcast_path=path: self._log_task_result(broadcast_path, done)) + finally: + await self._stop_broadcast_tasks() + + async def _handle_broadcast(self, source_path: str, source_broadcast: moq.BroadcastConsumer) -> None: + try: + catalog = await source_broadcast.catalog() + except Exception: + self.logger.exception("failed to read catalog source=%s", source_path) + return + + audio_publications = self._audio_publications(source_path, catalog) + if not audio_publications: + self.logger.info("no supported audio tracks found source=%s", source_path) + return + + source = audio_publications[0] + if len(audio_publications) > 1: + self.logger.info( + "using first supported audio track source=%s selected_track=%s available_tracks=%s", + source_path, + source.source_name, + ",".join(publication.source_name for publication in audio_publications), + ) + + outputs = self._publish_outputs(source_path, source) + if not outputs: + self.logger.info("no translation outputs announced source=%s", source_path) + return + + track_tasks = [ + asyncio.create_task( + self._serve_dynamic_translation_requests(source_path, output, source_broadcast), + name=f"dynamic:{source_path}:{output.group.provider_name}", + ) + for output in outputs + ] + + try: + await asyncio.gather(*track_tasks) + finally: + for task in track_tasks: + task.cancel() + await asyncio.gather(*track_tasks, return_exceptions=True) + for output in outputs: + self._finish_output(output) + + def _audio_publications( + self, + source_path: str, + catalog: moq.Catalog, + ) -> list[AudioPublication]: + publications: list[AudioPublication] = [] + + for source_name, audio in sorted(catalog.audio.items()): + try: + source = audio_publication(source_name, audio) + except ValueError as exc: + self.logger.warning( + "skipping audio track with unsupported parameters source=%s track=%s codec=%s reason=%s", + source_path, + source_name, + audio.codec, + exc, + ) + continue + + if source is None: + self.logger.info( + "skipping unsupported audio track source=%s track=%s codec=%s", + source_path, + source_name, + audio.codec, + ) + continue + + publications.append(source) + + return publications + + def _publish_outputs( + self, + source_path: str, + source: AudioPublication, + ) -> list[TranslationOutput]: + outputs: list[TranslationOutput] = [] + + for group in self.translation_groups: + output_path = translation_path(source_path, group.provider_name) + output_broadcast = moq.BroadcastProducer() + + try: + dynamic = output_broadcast.dynamic() + except Exception: + self.logger.exception( + "failed to create dynamic translation source=%s translation=%s provider=%s", + source_path, + output_path, + group.provider_name, + ) + continue + + try: + self.client.publish(output_path, output_broadcast) + except Exception: + self.logger.exception( + "failed to announce translation source=%s translation=%s", + source_path, + output_path, + ) + with contextlib.suppress(Exception): + dynamic.cancel() + with contextlib.suppress(Exception): + output_broadcast.finish() + continue + + self.logger.info( + "announcing dynamic translation source=%s translation=%s provider=%s languages=%s source_track=%s", + source_path, + output_path, + group.provider_name, + ",".join(group.languages.values()), + source.source_name, + ) + outputs.append( + TranslationOutput( + group=group, + path=output_path, + broadcast=output_broadcast, + dynamic=dynamic, + source=source, + translator=TrackTranslator(group.provider, logger=self.logger), + ) + ) + + return outputs + + async def _serve_dynamic_translation_requests( + self, + source_path: str, + output: TranslationOutput, + source_broadcast: moq.BroadcastConsumer, + ) -> None: + request_tasks: set[asyncio.Task[None]] = set() + active_translations: dict[str, ActiveTranslation] = {} + + try: + while True: + try: + requested_track = await output.dynamic.requested_track() + except asyncio.CancelledError: + raise + except Exception: + self.logger.exception( + "dynamic translation request loop failed source=%s translation=%s provider=%s", + source_path, + output.path, + output.group.provider_name, + ) + return + + if requested_track is None: + self.logger.info( + "dynamic translation closed source=%s translation=%s provider=%s", + source_path, + output.path, + output.group.provider_name, + ) + return + + transcript_language = parse_transcript_track_name(requested_track.name) + if transcript_language is None: + handler = self._handle_requested_translation_track( + source_path, + output, + source_broadcast, + requested_track, + active_translations, + ) + task_kind = "audio" + else: + handler = self._handle_requested_transcript_track( + source_path, + output, + requested_track, + active_translations, + ) + task_kind = "transcript" + + task = asyncio.create_task( + handler, + name=( + f"request:{task_kind}:{source_path}:" + f"{output.group.provider_name}:{requested_track.name}" + ), + ) + request_tasks.add(task) + task.add_done_callback( + lambda done, tasks=request_tasks, output_path=output.path: ( + tasks.discard(done), + self._log_requested_track_result(output_path, done), + ) + ) + finally: + with contextlib.suppress(Exception): + output.dynamic.cancel() + for task in request_tasks: + task.cancel() + await asyncio.gather(*request_tasks, return_exceptions=True) + + async def _handle_requested_translation_track( + self, + source_path: str, + output: TranslationOutput, + source_broadcast: moq.BroadcastConsumer, + requested_track: moq.TrackProducer, + active_translations: dict[str, ActiveTranslation], + ) -> None: + source = output.source + source_name = source.source_name + requested_language = requested_track.name.strip() + target_language = output.group.languages.get(_language_key(requested_language)) + + if target_language is None: + self.logger.info( + "rejecting unsupported translation request source=%s translation=%s " + "provider=%s requested_track=%s allowed_languages=%s", + source_path, + output.path, + output.group.provider_name, + requested_track.name, + ",".join(output.group.languages.values()), + ) + self._finish_track(requested_track) + return + + language_key = _language_key(target_language) + active = active_translations.get(language_key) + if active is not None and not active.done.is_set(): + output_media = self._publish_requested_translation_media( + source_path=source_path, + output=output, + requested_track=requested_track, + target_language=target_language, + ) + if output_media is None: + return + + active.media.add(output_media) + self.logger.info( + "attached translation subscriber source=%s translation=%s provider=%s " + "target_language=%s output_track=%s", + source_path, + output.path, + output.group.provider_name, + target_language, + output_media.name, + ) + await self._serve_translation_media_demand( + source_path=source_path, + output=output, + active=active, + output_media=output_media, + target_language=target_language, + ) + return + if active is not None: + active_translations.pop(language_key, None) + + output_media = self._publish_requested_translation_media( + source_path=source_path, + output=output, + requested_track=requested_track, + target_language=target_language, + ) + if output_media is None: + return + + transcript = TranscriptPublisher(logger=self.logger) + media = MediaFanout(logger=self.logger) + media.add(output_media) + active = ActiveTranslation( + transcript=transcript, + done=asyncio.Event(), + media=media, + ) + active_translations[language_key] = active + + self.logger.info( + "starting translation source=%s source_track=%s translation=%s " + "provider=%s target_language=%s output_track=%s transcript_track=%s", + source_path, + source_name, + output.path, + output.group.provider_name, + target_language, + output_media.name, + transcript_track_name(target_language), + ) + + source_consumer: moq.MediaConsumer | None = None + forward_task: asyncio.Task[None] | None = None + empty_task: asyncio.Task[None] | None = None + media_task: asyncio.Task[None] | None = None + + try: + source_consumer = source_broadcast.subscribe_media( + source_name, + source.audio.container, + self.max_latency_ms, + ) + + forward_task = asyncio.create_task( + output.translator.run( + source_consumer=source_consumer, + output_media=media, + source=source, + context=TranslationContext( + source_path=source_path, + source_track=source_name, + target_language=target_language, + ), + transcript=transcript, + ), + name=f"forward:{source_path}:{source_name}:{target_language}", + ) + media_task = asyncio.create_task( + self._serve_translation_media_demand( + source_path=source_path, + output=output, + active=active, + output_media=output_media, + target_language=target_language, + ), + name=f"demand:{output.path}:{output_media.name}", + ) + empty_task = asyncio.create_task( + media.empty.wait(), + name=f"empty:{output.path}:{target_language}", + ) + + while True: + done, pending = await asyncio.wait( + {forward_task, empty_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + + if forward_task in done: + break + + if empty_task in done: + if media.has_demand: + empty_task = asyncio.create_task( + media.empty.wait(), + name=f"empty:{output.path}:{target_language}", + ) + continue + + source_consumer.cancel() + self.logger.info( + "closing translation; no active subscribers after grace source=%s " + "source_track=%s output_track=%s target_language=%s grace_seconds=%.1f", + source_path, + source_name, + output_media.name, + target_language, + TRANSLATION_UNUSED_GRACE_SECONDS, + ) + return + + for task in pending: + task.cancel() + + exc = forward_task.exception() + if exc is not None: + raise exc + + self.logger.info( + "source audio ended source=%s source_track=%s output_track=%s target_language=%s", + source_path, + source_name, + output_media.name, + target_language, + ) + except asyncio.CancelledError: + if source_consumer is not None: + source_consumer.cancel() + raise + except Exception: + if source_consumer is not None: + source_consumer.cancel() + self.logger.exception( + "translation failed source=%s source_track=%s output_track=%s target_language=%s", + source_path, + source_name, + output_media.name, + target_language, + ) + finally: + active.done.set() + if active_translations.get(language_key) is active: + del active_translations[language_key] + if forward_task is not None: + await self._cancel_task(forward_task) + if empty_task is not None: + await self._cancel_task(empty_task) + if media_task is not None: + await self._cancel_task(media_task) + transcript.close() + media.close() + + def _publish_requested_translation_media( + self, + *, + source_path: str, + output: TranslationOutput, + requested_track: moq.TrackProducer, + target_language: str, + ) -> moq.MediaProducer | None: + try: + return output.broadcast.publish_requested_media( + requested_track, + output.source.format, + output.source.init, + ) + except Exception: + self.logger.exception( + "failed to create requested translation media source=%s translation=%s " + "provider=%s target_language=%s requested_track=%s codec=%s", + source_path, + output.path, + output.group.provider_name, + target_language, + requested_track.name, + output.source.audio.codec, + ) + self._finish_track(requested_track) + return None + + async def _serve_translation_media_demand( + self, + *, + source_path: str, + output: TranslationOutput, + active: ActiveTranslation, + output_media: moq.MediaProducer, + target_language: str, + ) -> None: + try: + while not active.done.is_set(): + became_inactive = await self._wait_for_translation_unused_grace( + source_path=source_path, + output=output, + active=active, + output_media=output_media, + target_language=target_language, + ) + if not became_inactive or active.done.is_set(): + return + + active.media.mark_inactive(output_media) + self.logger.info( + "translation media inactive after unused grace source=%s translation=%s " + "provider=%s target_language=%s output_track=%s", + source_path, + output.path, + output.group.provider_name, + target_language, + _safe_media_name(output_media), + ) + + became_active = await self._wait_for_translation_used_after_inactive( + source_path=source_path, + output=output, + active=active, + output_media=output_media, + target_language=target_language, + ) + if not became_active: + return + + active.media.mark_active(output_media) + self.logger.info( + "translation media active again source=%s translation=%s provider=%s " + "target_language=%s output_track=%s", + source_path, + output.path, + output.group.provider_name, + target_language, + _safe_media_name(output_media), + ) + finally: + active.media.mark_inactive(output_media) + + async def _wait_for_translation_used_after_inactive( + self, + *, + source_path: str, + output: TranslationOutput, + active: ActiveTranslation, + output_media: moq.MediaProducer, + target_language: str, + ) -> bool: + used_task = asyncio.create_task( + output_media.used(), + name=f"used-inactive:{output.path}:{output_media.name}", + ) + done_task = asyncio.create_task( + active.done.wait(), + name=f"translation-done:{output.path}:{target_language}:{output_media.name}", + ) + + try: + done, _pending = await asyncio.wait( + {used_task, done_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + if done_task in done: + return False + try: + used_task.result() + except Exception: + self.logger.debug( + "translation used wait ended source=%s translation=%s provider=%s " + "target_language=%s output_track=%s", + source_path, + output.path, + output.group.provider_name, + target_language, + _safe_media_name(output_media), + exc_info=True, + ) + return False + return True + finally: + await self._cancel_task(used_task) + await self._cancel_task(done_task) + + async def _wait_for_translation_unused_grace( + self, + *, + source_path: str, + output: TranslationOutput, + active: ActiveTranslation, + output_media: moq.MediaProducer, + target_language: str, + ) -> bool: + while True: + unused_task = asyncio.create_task( + output_media.unused(), + name=f"unused:{output.path}:{output_media.name}", + ) + done_task = asyncio.create_task( + active.done.wait(), + name=f"translation-done:{output.path}:{target_language}:{output_media.name}", + ) + try: + done, _pending = await asyncio.wait( + {unused_task, done_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + if done_task in done: + return False + try: + unused_task.result() + except Exception: + self.logger.debug( + "translation unused wait ended source=%s translation=%s provider=%s " + "target_language=%s output_track=%s", + source_path, + output.path, + output.group.provider_name, + target_language, + _safe_media_name(output_media), + exc_info=True, + ) + return False + finally: + await self._cancel_task(unused_task) + await self._cancel_task(done_task) + + self.logger.info( + "translation unused; waiting for subscribers source=%s translation=%s " + "provider=%s target_language=%s output_track=%s grace_seconds=%.1f", + source_path, + output.path, + output.group.provider_name, + target_language, + output_media.name, + TRANSLATION_UNUSED_GRACE_SECONDS, + ) + + used_task = asyncio.create_task( + output_media.used(), + name=f"used:{output.path}:{output_media.name}", + ) + done_task = asyncio.create_task( + active.done.wait(), + name=f"translation-done:{output.path}:{target_language}:{output_media.name}", + ) + try: + done, _pending = await asyncio.wait( + {used_task, done_task}, + timeout=TRANSLATION_UNUSED_GRACE_SECONDS, + return_when=asyncio.FIRST_COMPLETED, + ) + if done_task in done: + return False + if used_task not in done: + return True + try: + used_task.result() + except Exception: + self.logger.debug( + "translation used wait ended source=%s translation=%s provider=%s " + "target_language=%s output_track=%s", + source_path, + output.path, + output.group.provider_name, + target_language, + _safe_media_name(output_media), + exc_info=True, + ) + return False + finally: + await self._cancel_task(used_task) + await self._cancel_task(done_task) + + self.logger.info( + "translation reused during unused grace source=%s translation=%s " + "provider=%s target_language=%s output_track=%s", + source_path, + output.path, + output.group.provider_name, + target_language, + output_media.name, + ) + + async def _handle_requested_transcript_track( + self, + source_path: str, + output: TranslationOutput, + requested_track: moq.TrackProducer, + active_translations: dict[str, ActiveTranslation], + ) -> None: + requested_language = parse_transcript_track_name(requested_track.name) + if requested_language is None: + self._finish_track(requested_track) + return + + target_language = output.group.languages.get(_language_key(requested_language)) + if target_language is None: + self.logger.info( + "rejecting unsupported transcript request source=%s translation=%s " + "provider=%s requested_track=%s allowed_languages=%s", + source_path, + output.path, + output.group.provider_name, + requested_track.name, + ",".join(output.group.languages.values()), + ) + self._finish_track(requested_track) + return + + language_key = _language_key(target_language) + active = active_translations.get(language_key) + if active is None or active.done.is_set(): + self.logger.info( + "rejecting transcript request without active audio source=%s translation=%s " + "provider=%s target_language=%s requested_track=%s", + source_path, + output.path, + output.group.provider_name, + target_language, + requested_track.name, + ) + self._finish_track(requested_track) + return + + self.logger.info( + "starting transcript stream source=%s translation=%s provider=%s " + "target_language=%s track=%s", + source_path, + output.path, + output.group.provider_name, + target_language, + requested_track.name, + ) + + active.transcript.attach(requested_track) + unused_task = asyncio.create_task( + requested_track.unused(), + name=f"unused-transcript:{output.path}:{requested_track.name}", + ) + audio_done_task = asyncio.create_task( + active.done.wait(), + name=f"audio-done:{output.path}:{target_language}", + ) + + try: + done, _pending = await asyncio.wait( + {unused_task, audio_done_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + if unused_task in done: + self.logger.info( + "closing transcript stream; no active subscribers source=%s " + "translation=%s target_language=%s track=%s", + source_path, + output.path, + target_language, + requested_track.name, + ) + else: + self.logger.info( + "closing transcript stream; audio translation stopped source=%s " + "translation=%s target_language=%s track=%s", + source_path, + output.path, + target_language, + requested_track.name, + ) + finally: + await self._cancel_task(unused_task) + await self._cancel_task(audio_done_task) + active.transcript.detach(requested_track) + + def _finish_track(self, track: moq.TrackProducer) -> None: + with contextlib.suppress(Exception): + track.finish() + + async def _stop_broadcast_tasks(self) -> None: + tasks = list(self._broadcast_tasks.values()) + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + + async def _cancel_task(self, task: asyncio.Task[object]) -> None: + if task.done(): + with contextlib.suppress(asyncio.CancelledError, Exception): + task.result() + return + + task.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await task + + def _finish_output(self, output: TranslationOutput) -> None: + with contextlib.suppress(Exception): + output.dynamic.cancel() + with contextlib.suppress(Exception): + output.broadcast.finish() + + def _log_requested_track_result(self, output_path: str, task: asyncio.Task[None]) -> None: + if task.cancelled(): + return + try: + task.result() + except asyncio.CancelledError: + return + except Exception: + self.logger.exception("requested translation track task failed translation=%s", output_path) + + def _log_task_result(self, broadcast_path: str, task: asyncio.Task[None]) -> None: + if task.cancelled(): + return + try: + task.result() + except asyncio.CancelledError: + return + except Exception: + self.logger.exception("broadcast task failed path=%s", broadcast_path) + + +def _provider_name(provider: TranslationProvider, override: str | None) -> str: + if override is not None: + if not override.strip(): + raise ValueError("provider_name must not be empty") + return override + + name = getattr(provider, "name", None) + if isinstance(name, str) and name.strip(): + return name + + raise ValueError("provider_name must be provided when provider has no name") + + +def _safe_media_name(media: moq.MediaProducer) -> str: + with contextlib.suppress(Exception): + return media.name + return "" + + +async def run( + *, + url: str, + prefix: str = "", + tls_verify: bool = True, + max_latency_ms: int = 10_000, + provider: TranslationProvider | None = None, + provider_name: str | None = None, + providers: Sequence[tuple[str, TranslationProvider]] | None = None, + translations: Sequence[TranslationSpec] | None = None, +) -> None: + translations = _translation_specs( + translations=translations, + provider=provider, + provider_name=provider_name, + providers=providers, + ) + publish_origin = moq.OriginProducer() + subscribe_origin = moq.OriginProducer() + + async with moq.Client( + url, + tls_verify=tls_verify, + publish=publish_origin, + subscribe=subscribe_origin, + ) as client: + service = TranslationService( + client, + prefix=prefix, + max_latency_ms=max_latency_ms, + translations=translations, + ) + await service.run() + + +TOKEN_REFRESH_MARGIN_SECONDS = 300.0 +RECONNECT_DELAY_SECONDS = 5.0 + + +async def run_with_fishjam( + *, + fishjam_id: str, + management_token: str, + prefix: str = "", + token_ttl: float = 3600.0, + tls_verify: bool = True, + max_latency_ms: int = 10_000, + provider: TranslationProvider | None = None, + provider_name: str | None = None, + providers: Sequence[tuple[str, TranslationProvider]] | None = None, + translations: Sequence[TranslationSpec] | None = None, +) -> None: + """Run the service against Fishjam, refreshing the MoQ token before it expires. + + Requests a token scoped to ``prefix`` for both publish and subscribe, so a + single connection covers every broadcast under the account root. + """ + translations = _translation_specs( + translations=translations, + provider=provider, + provider_name=provider_name, + providers=providers, + ) + reconnect_after = max(token_ttl - TOKEN_REFRESH_MARGIN_SECONDS, token_ttl / 2) + + while True: + try: + access = await fetch_moq_access( + fishjam_id, + management_token, + publish_path=prefix, + subscribe_path=prefix, + ) + except Exception: + LOGGER.exception( + "failed to fetch MoQ access from Fishjam; retrying in %ss", + RECONNECT_DELAY_SECONDS, + ) + await asyncio.sleep(RECONNECT_DELAY_SECONDS) + continue + + LOGGER.info("acquired MoQ token; connecting to %s", access.connection_url.split("?")[0]) + try: + async with asyncio.timeout(reconnect_after): + await run( + url=access.connection_url, + prefix=prefix, + tls_verify=tls_verify, + max_latency_ms=max_latency_ms, + translations=translations, + ) + except TimeoutError: + LOGGER.info("MoQ token expiring; refreshing and reconnecting") + except Exception: + LOGGER.exception( + "MoQ connection failed; reconnecting in %ss", RECONNECT_DELAY_SECONDS + ) + await asyncio.sleep(RECONNECT_DELAY_SECONDS) + + +def _translation_specs( + *, + translations: Sequence[TranslationSpec] | None, + provider: TranslationProvider | None, + provider_name: str | None, + providers: Sequence[tuple[str, TranslationProvider]] | None, +) -> tuple[TranslationSpec, ...]: + if translations is not None: + return _validate_translation_specs(translations) + + provider_specs = _provider_specs(provider, provider_name, providers) + specs = [] + seen: set[tuple[str, str]] = set() + + for name, provider in provider_specs: + name = _provider_name(provider, name) + for language in supported_target_languages(provider): + key = (name, language) + if key in seen: + raise ValueError( + f"duplicate translation target: provider={name} target_language={language}" + ) + seen.add(key) + specs.append( + TranslationSpec( + provider_name=name, + target_language=language, + provider=provider, + ) + ) + + return tuple(specs) + + +def _validate_translation_specs( + translations: Sequence[TranslationSpec], +) -> tuple[TranslationSpec, ...]: + if not translations: + raise ValueError("at least one translation provider/language is required") + + specs = [] + seen: set[tuple[str, str]] = set() + for spec in translations: + provider_name = _provider_name(spec.provider, spec.provider_name) + target_language = _canonical_supported_language( + spec.provider, + spec.target_language, + provider_name, + ) + + key = (provider_name, target_language) + if key in seen: + raise ValueError( + f"duplicate translation target: provider={provider_name} target_language={target_language}" + ) + seen.add(key) + specs.append( + TranslationSpec( + provider_name=provider_name, + target_language=target_language, + provider=spec.provider, + ) + ) + + return tuple(specs) + + +def _translation_groups(translations: Sequence[TranslationSpec]) -> list[TranslationGroup]: + groups: dict[str, TranslationGroup] = {} + + for spec in translations: + provider_name = _provider_name(spec.provider, spec.provider_name) + target_language = _canonical_supported_language( + spec.provider, + spec.target_language, + provider_name, + ) + + group = groups.get(provider_name) + if group is None: + group = TranslationGroup( + provider_name=provider_name, + provider=spec.provider, + languages={}, + ) + groups[provider_name] = group + + language_key = _language_key(target_language) + if language_key in group.languages: + raise ValueError( + f"duplicate translation target for provider={provider_name}: " + f"{target_language} conflicts with {group.languages[language_key]}" + ) + group.languages[language_key] = target_language + + return list(groups.values()) + + +def _language_key(language: str) -> str: + return language.strip().lower() + + +def _provider_specs( + provider: TranslationProvider | None, + provider_name: str | None, + providers: Sequence[tuple[str, TranslationProvider]] | None, +) -> Sequence[tuple[str, TranslationProvider]]: + if providers is not None: + if not providers: + raise ValueError("at least one provider is required") + return providers + + provider = provider or GoogleTranslationProvider() + return [(_provider_name(provider, provider_name), provider)] + + +def supported_target_languages(provider: TranslationProvider) -> tuple[str, ...]: + languages = tuple( + language.strip() + for language in getattr(provider, "supported_target_languages", ()) + if language.strip() + ) + if not languages: + raise ValueError(f"provider has no supported target languages: {provider!r}") + return languages + + +def _canonical_supported_language( + provider: TranslationProvider, + language: str, + provider_name: str, +) -> str: + requested = language.strip() + if not requested: + raise ValueError("target language must not be empty") + + supported = { + _language_key(supported_language): supported_language + for supported_language in supported_target_languages(provider) + } + canonical = supported.get(_language_key(requested)) + if canonical is None: + supported_values = ", ".join(supported.values()) + raise ValueError( + f"unsupported target language for provider={provider_name}: " + f"{requested}; supported: {supported_values}" + ) + return canonical diff --git a/translation-demo/service/translator/stats.py b/translation-demo/service/translator/stats.py new file mode 100644 index 0000000..9349de6 --- /dev/null +++ b/translation-demo/service/translator/stats.py @@ -0,0 +1,124 @@ +"""Per-track translation session statistics.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field + + +SESSION_STATS_LOG_INTERVAL_SECONDS = 5.0 + + +@dataclass +class MeasurementStats: + count: int = 0 + total_us: int = 0 + latest_us: int | None = None + max_us: int | None = None + + def record(self, value_us: int) -> None: + self.count += 1 + self.total_us += value_us + self.latest_us = value_us + self.max_us = value_us if self.max_us is None else max(self.max_us, value_us) + + @property + def average_us(self) -> float | None: + if not self.count: + return None + return self.total_us / self.count + + +@dataclass +class SessionStats: + decode_timing: MeasurementStats = field(default_factory=MeasurementStats) + encode_timing: MeasurementStats = field(default_factory=MeasurementStats) + model_latency: MeasurementStats = field(default_factory=MeasurementStats) + first_response_latency_us: int | None = None + latest_model_input_end_us: int | None = None + latest_model_output_end_us: int | None = None + + def record_decode_timing(self, elapsed_us: int) -> None: + self.decode_timing.record(max(0, elapsed_us)) + + def record_encode_timing(self, elapsed_us: int) -> None: + self.encode_timing.record(max(0, elapsed_us)) + + def record_model_input_end(self, timestamp_us: int) -> None: + self.latest_model_input_end_us = timestamp_us + self._record_current_model_latency() + + def record_model_output_end(self, timestamp_us: int) -> None: + self.latest_model_output_end_us = timestamp_us + self._record_current_model_latency() + + def record_first_response_latency(self, elapsed_us: int) -> None: + if self.first_response_latency_us is None: + self.first_response_latency_us = max(0, elapsed_us) + + def log( + self, + logger: logging.Logger, + *, + provider_name: str, + source_path: str, + source_track: str, + target_language: str, + ) -> None: + if not self.has_activity: + return + + logger.debug( + ( + "%s session stats model_latency_ms=%s model_latency_avg_ms=%s " + "model_latency_max_ms=%s first_response_latency_ms=%s " + "decode_count=%d decode_latest_ms=%s decode_avg_ms=%s decode_max_ms=%s " + "encode_count=%d encode_latest_ms=%s encode_avg_ms=%s encode_max_ms=%s " + "latest_input_end_ms=%s latest_output_end_ms=%s " + "source=%s source_track=%s target_language=%s" + ), + provider_name, + _format_ms(self.model_latency.latest_us), + _format_ms(self.model_latency.average_us), + _format_ms(self.model_latency.max_us), + _format_ms(self.first_response_latency_us), + self.decode_timing.count, + _format_ms(self.decode_timing.latest_us), + _format_ms(self.decode_timing.average_us), + _format_ms(self.decode_timing.max_us), + self.encode_timing.count, + _format_ms(self.encode_timing.latest_us), + _format_ms(self.encode_timing.average_us), + _format_ms(self.encode_timing.max_us), + _format_ms(self.latest_model_input_end_us), + _format_ms(self.latest_model_output_end_us), + source_path, + source_track, + target_language, + ) + + @property + def has_activity(self) -> bool: + return bool( + self.decode_timing.count + or self.encode_timing.count + or self.model_latency.count + or self.first_response_latency_us is not None + ) + + def _record_current_model_latency(self) -> None: + if ( + self.latest_model_input_end_us is None + or self.latest_model_output_end_us is None + ): + return + + self.model_latency.record( + self.latest_model_input_end_us - self.latest_model_output_end_us + ) + + +def _format_ms(value_us: float | int | None) -> str: + if value_us is None: + return "n/a" + return f"{value_us / 1_000:.1f}" diff --git a/translation-demo/service/translator/transcript.py b/translation-demo/service/translator/transcript.py new file mode 100644 index 0000000..b64a57f --- /dev/null +++ b/translation-demo/service/translator/transcript.py @@ -0,0 +1,171 @@ +"""Publishing translated transcript text to a raw MoQ caption track.""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import logging +from dataclasses import dataclass + +import moq_net as moq + + +LOGGER = logging.getLogger(__name__) + +# Wire contract (v2) shared with the videoroom client: each caption update is +# one group containing a single UTF-8 frame with the full current caption +# state as JSON (replace semantics): +# +# {"segments": [{"text": str, "ts_us": int, "final": bool}, ...]} +# +# `ts_us` is the segment start on the same output clock as the translated +# audio frame timestamps; the client holds each segment until audio playback +# reaches it. `{"segments": []}` clears the caption. A segment's `ts_us` never +# changes once published (the client keys per-segment display timing on it). +# The track is not advertised in the Hang catalog (the FFI catalog only +# carries audio/video), so clients subscribe to the dynamic text track that +# belongs to the active audio language track directly. +TRANSCRIPT_TRACK_SUFFIX = "transcript.json" + +IDLE_CLEAR_SECONDS = 4.0 +# Drop segments this much older than the newest output audio position. +WINDOW_US = 15_000_000 +# Close a segment once it grows past this, even mid-sentence. +MAX_SEGMENT_CHARS = 80 +# Hard cap on the published window, newest segments win. +MAX_SEGMENTS = 12 + +_SENTENCE_ENDINGS = (".", "!", "?", "…", "。", "!", "?") + + +@dataclass +class _Segment: + text: str + ts_us: int + final: bool = False + + +class TranscriptPublisher: + """Groups provider transcript deltas and publishes them to attached tracks.""" + + def __init__( + self, + *, + idle_clear_seconds: float = IDLE_CLEAR_SECONDS, + logger: logging.Logger = LOGGER, + ) -> None: + self.idle_clear_seconds = idle_clear_seconds + self.logger = logger + self._tracks: list[moq.TrackProducer] = [] + self._segments: list[_Segment] = [] + self._clear_task: asyncio.Task[None] | None = None + + def attach(self, track: moq.TrackProducer) -> None: + """Attach a requested transcript track to this active audio translation.""" + if not self._contains(track): + self._tracks.append(track) + self._publish(track) + + def detach(self, track: moq.TrackProducer, *, finish: bool = True) -> None: + self._tracks = [attached for attached in self._tracks if attached is not track] + if finish: + with contextlib.suppress(Exception): + track.finish() + + def handle_delta(self, delta: str, *, clock_us: int = 0) -> None: + """Append a provider transcript delta at the given output-audio position. + + `clock_us` is the output clock of the audio burst the delta belongs to + (the first encoded frame of the burst, or the running clock when the + text arrives ahead of its audio). It stamps newly started segments + only; an existing open segment keeps its original timestamp. + """ + if not delta: + return + + current = self._segments[-1] if self._segments and not self._segments[-1].final else None + if current is None: + current = _Segment(text="", ts_us=int(clock_us)) + self._segments.append(current) + current.text += delta + + if current.text.rstrip().endswith(_SENTENCE_ENDINGS) or len(current.text) >= MAX_SEGMENT_CHARS: + current.final = True + + self._expire(int(clock_us)) + self._publish() + self._restart_clear_timer() + + def close(self) -> None: + """Clear published captions, finish attached tracks, and stop the idle timer.""" + self._cancel_clear_timer() + self._clear() + for track in tuple(self._tracks): + self.detach(track) + + def _expire(self, clock_us: int) -> None: + horizon = max(clock_us, *(segment.ts_us for segment in self._segments)) - WINDOW_US + self._segments = [ + segment for segment in self._segments if segment.ts_us >= horizon or not segment.final + ] + if len(self._segments) > MAX_SEGMENTS: + self._segments = self._segments[-MAX_SEGMENTS:] + + def _clear(self) -> None: + if not self._segments: + return + self._segments = [] + self._publish() + + def _publish(self, track: moq.TrackProducer | None = None) -> None: + payload = json.dumps( + { + "segments": [ + {"text": segment.text.strip(), "ts_us": segment.ts_us, "final": segment.final} + for segment in self._segments + if segment.text.strip() + ] + }, + ensure_ascii=False, + ).encode("utf-8") + + tracks = (track,) if track is not None else tuple(self._tracks) + for attached in tracks: + try: + attached.write_frame(payload) + except Exception: + self.logger.debug("failed to write transcript frame", exc_info=True) + + def _restart_clear_timer(self) -> None: + self._cancel_clear_timer() + self._clear_task = asyncio.create_task( + self._clear_after_idle(), + name="transcript-clear", + ) + + def _cancel_clear_timer(self) -> None: + if self._clear_task is not None: + self._clear_task.cancel() + self._clear_task = None + + async def _clear_after_idle(self) -> None: + await asyncio.sleep(self.idle_clear_seconds) + self._clear() + + def _contains(self, track: moq.TrackProducer) -> bool: + return any(attached is track for attached in self._tracks) + + +def transcript_track_name(language: str) -> str: + language = language.strip() + if not language: + raise ValueError("language must not be empty") + return f"{language}/{TRANSCRIPT_TRACK_SUFFIX}" + + +def parse_transcript_track_name(track_name: str) -> str | None: + language, separator, suffix = track_name.strip().partition("/") + if not separator or not language or suffix != TRANSCRIPT_TRACK_SUFFIX: + return None + return language diff --git a/translation-demo/service/translator/translator.py b/translation-demo/service/translator/translator.py new file mode 100644 index 0000000..093efa2 --- /dev/null +++ b/translation-demo/service/translator/translator.py @@ -0,0 +1,179 @@ +"""Provider-neutral track translation orchestration.""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +from typing import Protocol + +from .catalog import AudioPublication +from .media import AudioTranslationPipeline +from .moq_compat import moq +from .providers import TranslationContext, TranslationProvider +from .stats import SESSION_STATS_LOG_INTERVAL_SECONDS +from .transcript import TranscriptPublisher + + +LOGGER = logging.getLogger(__name__) + + +class MediaOutput(Protocol): + def write_frame(self, payload: bytes, timestamp_us: int) -> None: ... + + +class TrackTranslator: + """Runs one active source track through decode, provider, and encode.""" + + def __init__( + self, + provider: TranslationProvider, + *, + logger: logging.Logger = LOGGER, + ) -> None: + self.provider = provider + self.logger = logger + + async def run( + self, + *, + source_consumer: moq.MediaConsumer, + output_media: MediaOutput, + source: AudioPublication, + context: TranslationContext, + transcript: TranscriptPublisher | None = None, + ) -> None: + session = await self.provider.start(context) + send_task: asyncio.Task[None] | None = None + receive_task: asyncio.Task[None] | None = None + stats_task: asyncio.Task[None] | None = None + + try: + pipeline = AudioTranslationPipeline( + source.audio, + provider_input=session.input_format, + provider_output=session.output_format, + stats=context.stats, + ) + provider_name = _provider_label( + getattr(self.provider, "name", self.provider.__class__.__name__) + ) + stats_task = asyncio.create_task( + self._log_session_stats(context, provider_name), + name=f"translation-stats:{context.source_path}:{context.source_track}", + ) + + send_task = asyncio.create_task( + self._send_source_audio(source_consumer, session, pipeline), + name=f"provider-send:{context.source_path}:{context.source_track}", + ) + receive_task = asyncio.create_task( + self._receive_translated_audio(output_media, session, pipeline, transcript), + name=f"provider-receive:{context.source_path}:{context.source_track}", + ) + + done, pending = await asyncio.wait( + {send_task, receive_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + + for task in done: + if task.cancelled(): + raise asyncio.CancelledError + exc = task.exception() + if exc is not None: + raise exc + + await session.close() + for task in pending: + task.cancel() + await asyncio.gather(*pending, return_exceptions=True) + + for encoded in pipeline.finish(): + output_media.write_frame(encoded.payload, encoded.timestamp_us) + finally: + await self._close_session(session) + if send_task is not None: + await self._cancel_task(send_task) + if receive_task is not None: + await self._cancel_task(receive_task) + if stats_task is not None: + await self._cancel_task(stats_task) + + async def _send_source_audio(self, source_consumer, session, pipeline: AudioTranslationPipeline) -> None: + async for frame in source_consumer: + for chunk in pipeline.decode_source(frame): + await session.send_audio(chunk) + + async def _receive_translated_audio( + self, + output_media: MediaOutput, + session, + pipeline: AudioTranslationPipeline, + transcript: TranscriptPublisher | None, + ) -> None: + clock_us = 0 + while True: + event = await session.receive() + if event is None: + return + + # One provider event may carry both audio and transcript text. + # Encode the audio first so a transcript delta from the same event + # is stamped with its own burst's output position; a text-only + # event uses the running clock (the upcoming burst starts there, + # since the output timeline is continuous). + burst_start_us: int | None = None + if event.audio is not None: + for encoded in pipeline.encode_translation(event.audio): + output_media.write_frame(encoded.payload, encoded.timestamp_us) + if burst_start_us is None: + burst_start_us = encoded.timestamp_us + clock_us = encoded.timestamp_us + + if event.transcript is not None: + self.logger.debug( + "translation transcript kind=%s delta=%r", + event.transcript.kind, + event.transcript.delta, + ) + if transcript is not None and event.transcript.kind == "output": + transcript.handle_delta( + event.transcript.delta, + clock_us=burst_start_us if burst_start_us is not None else clock_us, + ) + + async def _close_session(self, session) -> None: + with contextlib.suppress(Exception): + await session.close() + + async def _log_session_stats( + self, context: TranslationContext, provider_name: str + ) -> None: + while True: + await asyncio.sleep(SESSION_STATS_LOG_INTERVAL_SECONDS) + context.stats.log( + self.logger, + provider_name=provider_name, + source_path=context.source_path, + source_track=context.source_track, + target_language=context.target_language, + ) + + async def _cancel_task(self, task: asyncio.Task[object]) -> None: + if task.done(): + with contextlib.suppress(asyncio.CancelledError, Exception): + task.result() + return + + task.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await task + + +def _provider_label(name: object) -> str: + normalized = str(name) + labels = { + "google": "Gemini", + } + return labels.get(normalized.lower(), normalized) diff --git a/translation-demo/service/uv.lock b/translation-demo/service/uv.lock new file mode 100644 index 0000000..bf92860 --- /dev/null +++ b/translation-demo/service/uv.lock @@ -0,0 +1,605 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[options] +prerelease-mode = "allow" + +[[package]] +name = "aenum" +version = "3.1.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/07/e9/8b283567c1fef7c24d1f390b37daede8b61593d8cdaffb8e95d571699e83/aenum-3.1.17.tar.gz", hash = "sha256:a969a4516b194895de72c875ece355f17c0d272146f7fda346ef74f93cf4d5ba", size = 137648, upload-time = "2026-03-20T20:43:29.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/8d/1fe30c6fd8999b9d462547c4a1bb6690bda24af38f2913c4bec7decb81f2/aenum-3.1.17-py3-none-any.whl", hash = "sha256:8b883a37a04e74cc838ac442bdd28c266eae5bbf13e1342c7ef123ed25230139", size = 165560, upload-time = "2026-03-20T20:43:27.681Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "av" +version = "17.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/e3/477fa20578c284abeda08d91b63ee9abaebc93445d8feeb989d3d444bae1/av-17.1.0.tar.gz", hash = "sha256:7f1e71ff621b66253333926f948e00faae11d855b2442133c65128bca64cdeb3", size = 4288546, upload-time = "2026-06-07T05:52:55.999Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/87/8036b5c781bc3639ea04ef42d4e26da253bd4bd4311d8705b6a1c8824047/av-17.1.0-cp311-abi3-macosx_11_0_x86_64.whl", hash = "sha256:ad7b4aa011093324b7118245f50ac6db244cfe9900d4072508a5245a2b0d3f41", size = 22460847, upload-time = "2026-06-07T05:52:04.261Z" }, + { url = "https://files.pythonhosted.org/packages/6d/af/dfdf6fc7b17814b50d0aa9e7a7e37b87be91be3890f44b0d525433cd1fd1/av-17.1.0-cp311-abi3-macosx_14_0_arm64.whl", hash = "sha256:43ebbe977f19a7f2d2bd1a4e119675a0b15e05852cf7309846b6ab922ba7ffe9", size = 18159115, upload-time = "2026-06-07T05:52:06.64Z" }, + { url = "https://files.pythonhosted.org/packages/ad/13/64f6c466471cea225b8b2f4cdc51a571f8a286984b55a08d169b932fda5d/av-17.1.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6a20658ec7d96a70e14b1196eff00b7cdd8831ac3b99868e16b8ba8b24090847", size = 33224427, upload-time = "2026-06-07T05:52:09.165Z" }, + { url = "https://files.pythonhosted.org/packages/77/43/96b35170bf2e64e00a41748c6400ff73232dc0fc62ded283679fb07c7fe0/av-17.1.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f9a65d1f48b818323fb411e80358f89d77dec340b01d27c6b2dfbb9cbf4b779f", size = 35370183, upload-time = "2026-06-07T05:52:11.959Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b3/8e8b4b6498731bfbd88e8399a756543f8088f1bd33d08eab678b5aebe728/av-17.1.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:58f7593726437cda5bd19793027e027768450b5c4a594777bf487798a33db702", size = 24459265, upload-time = "2026-06-07T05:52:14.66Z" }, + { url = "https://files.pythonhosted.org/packages/14/ac/ceb84b7553db21f1143d817245c560d9267168e1e58b1a8eeae2b62c4d04/av-17.1.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:bbab058bd965309f39962e53caac8126987c68c0be094fc4f9427e5615b0218f", size = 34283709, upload-time = "2026-06-07T05:52:17.389Z" }, + { url = "https://files.pythonhosted.org/packages/59/f9/4115fd84148c9a1cf365096694be6ac882fd3cd3cdb7a2f35e71fecf1631/av-17.1.0-cp311-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:9514cfda85180554c430695282faf4be3ffdf95775d8519733821244eecb58e0", size = 25397573, upload-time = "2026-06-07T05:52:20.012Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ac/92e52d5ed0e0b84d9d93e52b4338c2713d8a44082b8696e6516fdae7c4e4/av-17.1.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e1c90f85cd7431ede95b11e8e711571a896ebea433f298849c2c0f1594c8d86e", size = 36451495, upload-time = "2026-06-07T05:52:22.581Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f2/53a7cd34adb6a971d7e6d99663e74db286966c9db8afdca17472fdf0f98e/av-17.1.0-cp311-abi3-win_amd64.whl", hash = "sha256:5df5c1172ef1cf65a1529d612f7da7798ce2cf82c1ff7212466b538a6cc7214c", size = 28036393, upload-time = "2026-06-07T05:52:25.657Z" }, + { url = "https://files.pythonhosted.org/packages/66/47/cd9ae0edf2206351c1251bb94b5ec58728e42c5f6ee16c03c412f3a1bb3e/av-17.1.0-cp311-abi3-win_arm64.whl", hash = "sha256:ee98534242a74da847af78624779ac5a3177dc7c69f956a4da9e6f0fdb37d7f6", size = 21174601, upload-time = "2026-06-07T05:52:28.077Z" }, + { url = "https://files.pythonhosted.org/packages/36/90/b5668cddb3c401fcf22553bc495d5b0c6d8a01d118624b26f0db1d0b8653/av-17.1.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:5327807c1219293803ef0c5d1578ff3ae1cf638c09e5998962026e1a554ec240", size = 22699499, upload-time = "2026-06-07T05:52:30.335Z" }, + { url = "https://files.pythonhosted.org/packages/e0/7e/7be6bfddb823d045ff9fd5d4deb922ee3847605e162c3882e6c45b4c35ff/av-17.1.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:6c9b71fe5c0c5a8d303b1588d4d8ce9397d6b023f467cfef95000ba1f75507fa", size = 18366696, upload-time = "2026-06-07T05:52:32.645Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/391dcfa75c1ae1977efca44b753a11b929399b558826670c16a8808dd0e3/av-17.1.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:f997e3351bdf51127c07a74e21741a2996e9230cbeb2d81c14acde761b116c9c", size = 36582649, upload-time = "2026-06-07T05:52:35.218Z" }, + { url = "https://files.pythonhosted.org/packages/fb/32/7312854868b318b9d1b1dcbd1bddb460aaaeac7d57f816e11efec3bef5b1/av-17.1.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:efe9b1397300b67b644ad220c89df4892a76f2debe70f16bae1749fa20526e63", size = 38479390, upload-time = "2026-06-07T05:52:37.968Z" }, + { url = "https://files.pythonhosted.org/packages/2a/72/af47f59b4458e81ca7d89f477698dbfb3d5a0cd8ae6c1e4441d01074af8a/av-17.1.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:fa64e1f1500d01c4a98e7a41dc1a9a35fb4dfe71f5de0389264ec1192200c76a", size = 27127432, upload-time = "2026-06-07T05:52:40.371Z" }, + { url = "https://files.pythonhosted.org/packages/88/85/c2e6861baf0f8c7d21c4ce811d4d424fedac915e3910d3570ce4377717dc/av-17.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ffbd78d73d2c9bf31e9a007c992faec3991428b2941a3b085b84fb82e8c32d19", size = 37406592, upload-time = "2026-06-07T05:52:43.215Z" }, + { url = "https://files.pythonhosted.org/packages/ba/40/3cc13125aea976101c0858af99ac47257c0654411aa199b5d8e81eea7002/av-17.1.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bff8896454b38fcb785a70e5ae0485d7021cb776303a5849393128a30b8f850b", size = 28336228, upload-time = "2026-06-07T05:52:46.134Z" }, + { url = "https://files.pythonhosted.org/packages/a2/38/c7d9c3e746209a1a695c13e3aa7d817229e84a85d0a84271f313d1befdd3/av-17.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1284addf3c0dd939887a9722dc30df2241a97471ad52c3c507e31583ae22ff02", size = 39490680, upload-time = "2026-06-07T05:52:48.887Z" }, + { url = "https://files.pythonhosted.org/packages/a1/25/9d42da561b7b8f7dabdfaebba07b52977bee58c5c7e4285ac991abcfaa72/av-17.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:ec630be6321b04e317862f6082e84812bbd801e55a3c2298312e3fc8a0a4af4f", size = 28355673, upload-time = "2026-06-07T05:52:51.614Z" }, + { url = "https://files.pythonhosted.org/packages/a8/41/562a61d5a61fba3ffb273a115e249f1d8471b9515c59fcc38b4b9deda238/av-17.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b41647e42884bf543b8e8d0a1dabd4d1b006c99183eb1a2d7afc5b01f73eeff4", size = 21324700, upload-time = "2026-06-07T05:52:53.972Z" }, +] + +[[package]] +name = "betterproto" +version = "2.0.0b6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "grpclib" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/43/4c44efd75f2ef48a16b458c2fe2cff7aa74bab8fcadf2653bb5110a87f97/betterproto-2.0.0b6.tar.gz", hash = "sha256:720ae92697000f6fcf049c69267d957f0871654c8b0d7458906607685daee784", size = 63775, upload-time = "2023-06-26T19:43:54.319Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/6d/007abe4bf14cd02a3c07710c22f0e3252b55b06678de2535ae5f03b7433a/betterproto-2.0.0b6-py3-none-any.whl", hash = "sha256:a0839ec165d110a69d0d116f4d0e2bec8d186af4db826257931f0831dab73fcf", size = 64275, upload-time = "2023-06-26T19:43:52.013Z" }, +] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "fishjam-server-sdk" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aenum" }, + { name = "attrs" }, + { name = "betterproto" }, + { name = "flask-cors" }, + { name = "httpx" }, + { name = "python-dateutil" }, + { name = "urllib3" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/81/d33f5370c0a4f82acad6a7bcc42381aaa25c834b4c1c031349f1a63ab20b/fishjam_server_sdk-0.28.1.tar.gz", hash = "sha256:28718a9e8f035ff42ad1b47759c0009c29fb1962b253108c21872fc8905dd5cf", size = 40577, upload-time = "2026-06-23T13:55:33.153Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/45/2b76f66029c252298f92c441149e3c25f0f1be9225a1be78f8fffec95f0c/fishjam_server_sdk-0.28.1-py3-none-any.whl", hash = "sha256:4b10648a34cb0ce16370bea75072a42c4917f48c889dfabae71f9fbc6d281e36", size = 97662, upload-time = "2026-06-23T13:55:31.939Z" }, +] + +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, +] + +[[package]] +name = "flask-cors" +version = "6.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/47/03/4e464a50860f9adf08b5c1d3479cb8ea1f12af2aa69535c7042c6e628135/flask_cors-6.0.5.tar.gz", hash = "sha256:30c5031552cd59f620ac0c8211dac45b345d3b2df310e7721879e4f46ef9c601", size = 101386, upload-time = "2026-06-08T20:20:17.765Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/55/5bb1a2d918e9f02f131e47a59032bae70e48050e986e941511fd737a935c/flask_cors-6.0.5-py3-none-any.whl", hash = "sha256:68fcf75693e961f3af26683b23c4b9a8fb6b64de17d20d0c37b95e8de7ab2ed8", size = 16692, upload-time = "2026-06-08T20:20:16.247Z" }, +] + +[[package]] +name = "grpclib" +version = "0.4.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h2" }, + { name = "multidict" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/28/5a2c299ec82a876a252c5919aa895a6f1d1d35c96417c5ce4a4660dc3a80/grpclib-0.4.9.tar.gz", hash = "sha256:cc589c330fa81004c6400a52a566407574498cb5b055fa927013361e21466c46", size = 84798, upload-time = "2025-12-14T22:23:14.349Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/90/b0cbbd9efcc82816c58f31a34963071aa19fb792a212a5d9caf8e0fc3097/grpclib-0.4.9-py3-none-any.whl", hash = "sha256:7762ec1c8ed94dfad597475152dd35cbd11aecaaca2f243e29702435ca24cf0e", size = 77063, upload-time = "2025-12-14T22:23:13.224Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "h2" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, +] + +[[package]] +name = "hpack" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/5b/fcabf6028144a8723726318b07a32c2f3314acdff6265743cf08a344b18e/hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0", size = 51300, upload-time = "2026-06-23T18:34:46.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/b4/4a9fcfb2aef6ba44d9073ecd301443aa00b3dac95de5619f2a7de7ec8a91/hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986", size = 34246, upload-time = "2026-06-23T18:34:45.472Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "moq-ffi" +version = "0.2.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/b5/03952368282fdc8492ffb740201671e1cfd44f40d9777855386a5e399841/moq_ffi-0.2.21.tar.gz", hash = "sha256:d815772232f210e794616ed55fbb34704fabb09fabef56d627ee2f1743c1dc05", size = 1613999, upload-time = "2026-06-16T07:15:28.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/4b/b8db087a86dc010637e7bc4626c4c1dab0865859f2eb9b76e1b29c319e3d/moq_ffi-0.2.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:51273f13459f9fcb4a40c5a768dec640fbd42b51810ac0791ffe6d2bb1f309ec", size = 6915731, upload-time = "2026-06-16T07:15:17.059Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f0/2ee3dd4720f8db2c123d0bc8081084ff0f86c8b349902d59cde1fc52080f/moq_ffi-0.2.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b9a797acabd4dfa2221c70fd838271abf0fe88de2f49fd07e6e7fffbb577facc", size = 6552728, upload-time = "2026-06-16T07:15:19.142Z" }, + { url = "https://files.pythonhosted.org/packages/de/7b/7e605b76e2bb6ddffcbea1faeb71d7cd44f1ac36c6250ed73863a9be0ae1/moq_ffi-0.2.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7488e3b93b94e5f216cca4e09a0c6581c969e5c40560664256a3e5bcb61feb", size = 7347515, upload-time = "2026-06-16T07:15:21.224Z" }, + { url = "https://files.pythonhosted.org/packages/b8/bf/6842f3b9db44d25b4bab4969e68fea78f587e30e93f65c4c4aca368b700c/moq_ffi-0.2.21-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:d12a88543cf3944219c0bbfb6665d5546ad5b14d228003983e42782a6927c70f", size = 7414829, upload-time = "2026-06-16T07:15:23.638Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0d/a586635923d22dc31c3ff4f0517edcb9e0e767879510f608f7e090f10567/moq_ffi-0.2.21-py3-none-win_amd64.whl", hash = "sha256:85b4198f968e34ba907ae4e5d24021167a0bd291016f1fe97c8daa09b140a215", size = 5646579, upload-time = "2026-06-16T07:15:25.797Z" }, +] + +[[package]] +name = "moq-live-translation" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "av" }, + { name = "fishjam-server-sdk" }, + { name = "moq-net" }, + { name = "websockets" }, +] + +[package.metadata] +requires-dist = [ + { name = "av", specifier = ">=17.0.1" }, + { name = "fishjam-server-sdk", specifier = ">=0.28.1" }, + { name = "moq-net", specifier = ">=0.1.0" }, + { name = "websockets", specifier = ">=15.0" }, +] + +[[package]] +name = "moq-net" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "moq-ffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/2a/2edb812e3af05e8b655baa9c6076c4afa7394b887dedbaad87048a071c79/moq_net-0.1.0.tar.gz", hash = "sha256:db3be2698559ade55647bfa510b63f38d4f9fa80e3c0c3b71f801d47147758a7", size = 9488, upload-time = "2026-05-20T15:55:57.054Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/18/803a2d7b6350c3e202bccaa99e73d52e7a9b18ed4e36b5e4f41d2ad5b288/moq_net-0.1.0-py3-none-any.whl", hash = "sha256:2a0e8f32d153372556fff3a67390af541c0d89d2cad7d4bde757c4164680f3f2", size = 7624, upload-time = "2026-05-20T15:55:55.957Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, + { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, + { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, + { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, + { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, + { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "urllib3" +version = "1.26.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/e8/6ff5e6bc22095cfc59b6ea711b687e2b7ed4bdb373f7eeec370a97d7392f/urllib3-1.26.20.tar.gz", hash = "sha256:40c2dc0c681e47eb8f90e7e27bf6ff7df2e677421fd46756da1161c39ca70d32", size = 307380, upload-time = "2024-08-29T15:43:11.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/cf/8435d5a7159e2a9c83a95896ed596f68cf798005fe107cc655b5c5c14704/urllib3-1.26.20-py2.py3-none-any.whl", hash = "sha256:0ed14ccfbf1c30a9072c7ca157e4319b70d65f623e91e7b32fadb2853431016e", size = 144225, upload-time = "2024-08-29T15:43:08.921Z" }, +] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, + { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, + { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, + { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, + { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, +] diff --git a/translation-demo/src/utils/translation.ts b/translation-demo/src/utils/translation.ts index 4796ff9..8eb74dd 100644 --- a/translation-demo/src/utils/translation.ts +++ b/translation-demo/src/utils/translation.ts @@ -15,7 +15,6 @@ export const MEDIA_VISIBLE: Watch.Video.Visible = "always"; const TRANSLATION_SEGMENT = "translation"; const PROVIDER_LABELS: Record = { - openai: "OpenAI", google: "Google", };