From 7897e6d0ed9473bf9303c527aaaed7f6e2f1c470 Mon Sep 17 00:00:00 2001 From: Jeff Coggshall Date: Sun, 15 Feb 2026 16:09:30 -0800 Subject: [PATCH 1/5] moss foundation: codec + conversion + detection plumbing --- mlx_audio/codec/__init__.py | 2 +- mlx_audio/codec/models/__init__.py | 1 + .../models/moss_audio_tokenizer/README.md | 88 ++ .../models/moss_audio_tokenizer/__init__.py | 23 + .../models/moss_audio_tokenizer/config.py | 206 +++++ .../models/moss_audio_tokenizer/decoder.py | 48 + .../models/moss_audio_tokenizer/encoder.py | 48 + .../models/moss_audio_tokenizer/model.py | 861 ++++++++++++++++++ .../models/moss_audio_tokenizer/modules.py | 424 +++++++++ .../models/moss_audio_tokenizer/quantizer.py | 310 +++++++ .../codec/tests/test_moss_audio_tokenizer.py | 602 ++++++++++++ ...t_moss_audio_tokenizer_config_contracts.py | 45 + mlx_audio/convert.py | 21 +- mlx_audio/tts/utils.py | 1 + mlx_audio/utils.py | 13 +- scripts/convert_moss_models.py | 435 +++++++++ .../moss/MOSS-Audio-Tokenizer/config.json | 51 ++ .../MOSS-SoundEffect/config.json | 7 + .../MOSS-TTS-Local-Transformer/config.json | 8 + .../MOSS-TTS-Realtime/config.json | 6 + .../MOSS-TTS-HF-Repos/MOSS-TTS/config.json | 7 + .../MOSS-TTSD-v1.0/config.json | 7 + .../MOSS-Voice-Generator/config.json | 7 + tests/fixtures/moss/README.md | 11 + uv.lock | 536 +++++------ 25 files changed, 3502 insertions(+), 266 deletions(-) create mode 100644 mlx_audio/codec/models/moss_audio_tokenizer/README.md create mode 100644 mlx_audio/codec/models/moss_audio_tokenizer/__init__.py create mode 100644 mlx_audio/codec/models/moss_audio_tokenizer/config.py create mode 100644 mlx_audio/codec/models/moss_audio_tokenizer/decoder.py create mode 100644 mlx_audio/codec/models/moss_audio_tokenizer/encoder.py create mode 100644 mlx_audio/codec/models/moss_audio_tokenizer/model.py create mode 100644 mlx_audio/codec/models/moss_audio_tokenizer/modules.py create mode 100644 mlx_audio/codec/models/moss_audio_tokenizer/quantizer.py create mode 100644 mlx_audio/codec/tests/test_moss_audio_tokenizer.py create mode 100644 mlx_audio/codec/tests/test_moss_audio_tokenizer_config_contracts.py create mode 100644 scripts/convert_moss_models.py create mode 100644 tests/fixtures/moss/MOSS-Audio-Tokenizer/config.json create mode 100644 tests/fixtures/moss/MOSS-TTS-HF-Repos/MOSS-SoundEffect/config.json create mode 100644 tests/fixtures/moss/MOSS-TTS-HF-Repos/MOSS-TTS-Local-Transformer/config.json create mode 100644 tests/fixtures/moss/MOSS-TTS-HF-Repos/MOSS-TTS-Realtime/config.json create mode 100644 tests/fixtures/moss/MOSS-TTS-HF-Repos/MOSS-TTS/config.json create mode 100644 tests/fixtures/moss/MOSS-TTS-HF-Repos/MOSS-TTSD-v1.0/config.json create mode 100644 tests/fixtures/moss/MOSS-TTS-HF-Repos/MOSS-Voice-Generator/config.json create mode 100644 tests/fixtures/moss/README.md diff --git a/mlx_audio/codec/__init__.py b/mlx_audio/codec/__init__.py index 8b4bdd72b..4088ee6f8 100644 --- a/mlx_audio/codec/__init__.py +++ b/mlx_audio/codec/__init__.py @@ -1 +1 @@ -from .models import DAC, Encodec, Mimi, Vocos +from .models import DAC, Encodec, Mimi, MossAudioTokenizer, Vocos diff --git a/mlx_audio/codec/models/__init__.py b/mlx_audio/codec/models/__init__.py index 3180809c8..9664477b1 100644 --- a/mlx_audio/codec/models/__init__.py +++ b/mlx_audio/codec/models/__init__.py @@ -1,5 +1,6 @@ from .descript import DAC from .encodec import Encodec from .mimi import Mimi +from .moss_audio_tokenizer import MossAudioTokenizer from .snac import SNAC from .vocos import Vocos diff --git a/mlx_audio/codec/models/moss_audio_tokenizer/README.md b/mlx_audio/codec/models/moss_audio_tokenizer/README.md new file mode 100644 index 000000000..efdbfdde9 --- /dev/null +++ b/mlx_audio/codec/models/moss_audio_tokenizer/README.md @@ -0,0 +1,88 @@ +# MOSS Audio Tokenizer (Shared Codec) + +Shared codec used by all MOSS-TTS runtimes. + +- Package: `mlx_audio/codec/models/moss_audio_tokenizer/` +- Runtime class: `MossAudioTokenizer` +- Typical frame rate: `sampling_rate / downsample_rate` (24 kHz / 1920 => 12.5 Hz for upstream checkpoints) + +## Why This Matters For MOSS-TTS + +`moss_tts` and `moss_tts_realtime` both depend on this codec for: + +- reference-audio encoding into discrete codebook tokens +- generated token decoding back to waveform +- streaming decode paths with cache boundaries + +If codec contracts are wrong, generation may "run" but output quality/continuity will degrade. + +## Core APIs + +- `encode(input_values, ..., num_quantizers=None, chunk_duration=None)` +- `decode(audio_codes, ..., num_quantizers=None, chunk_duration=None)` +- `batch_encode(wav_list, num_quantizers=None)` +- `batch_decode(codes_list, num_quantizers=None)` +- `streaming_decode(audio_codes, chunk_tokens=..., num_quantizers=None)` + +## Audio-Code Shape Contracts + +`decode(...)` accepts both canonical and transposed layouts: + +- 2D: `(NQ, T)` or `(T, NQ)` +- 3D: `(NQ, B, T)` or `(B, T, NQ)` + +Internally, decode normalizes to canonical `(NQ, B, T)`. + +`batch_decode(...)` expects each list entry to resolve to `batch_size=1` after normalization. + +`streaming_decode(...)` currently supports only `batch_size=1`. + +## `num_quantizers` Behavior + +- If `num_quantizers` is omitted, runtime uses configured quantizer count. +- If provided, it must be `1..configured_nq`. +- Prefix decode is supported (decode with fewer quantizers than checkpoint max). + +Ambiguous shape ties are resolved with conservative rules favoring canonical orientation to preserve encode->decode round-trip behavior. + +## Chunked Encode/Decode + +When `chunk_duration` is used: + +- must be positive +- must be `<= causal_transformer_context_duration` +- `chunk_duration * sampling_rate` must be divisible by `downsample_rate` +- streaming chunked paths currently require `batch_size=1` + +Chunked decode and `streaming_decode(...)` include explicit `mx.eval(...)`/`mx.clear_cache()` boundaries to keep long runs bounded. + +## Checkpoint Sanitization + +`sanitize(...)` performs two key operations: + +1. Tensor layout transpose when checkpoint 3D layouts differ from current MLX parameter shapes. +2. Weight-norm merge for Conv1d params: + - combines `parametrizations.weight.original0` (`g`) + `original1` (`v`) + - produces standard `.weight` + +Missing weight-norm pairs fail fast. + +## Quantization Guardrail + +`model_quant_predicate(...)` disables quantization for embedding modules to protect codebook fidelity. + +## Load/Save + +- `from_pretrained(path_or_repo, strict=True)` supports local path or HF repo. +- `save_config(path)` writes canonicalized config JSON. + +MOSS runtime post-load hooks look for embedded codec folders first (`audio_tokenizer`, `moss_audio_tokenizer`, `codec`) and then fall back to `OpenMOSS-Team/MOSS-Audio-Tokenizer`. + +## Validation Anchors + +Primary tests: + +- `mlx_audio/codec/tests/test_moss_audio_tokenizer.py` +- `mlx_audio/codec/tests/test_moss_audio_tokenizer_config_contracts.py` + +These cover layout normalization, prefix decode semantics, tie-resolution edge cases, sanitize behavior, and streaming decode constraints. diff --git a/mlx_audio/codec/models/moss_audio_tokenizer/__init__.py b/mlx_audio/codec/models/moss_audio_tokenizer/__init__.py new file mode 100644 index 000000000..96310d464 --- /dev/null +++ b/mlx_audio/codec/models/moss_audio_tokenizer/__init__.py @@ -0,0 +1,23 @@ +from .config import ( + MossAudioTokenizerConfig, + MossAudioTokenizerModuleConfig, + MossAudioTokenizerQuantizerConfig, + load_moss_audio_tokenizer_config, +) +from .model import ( + MossAudioTokenizer, + MossAudioTokenizerDecoderOutput, + MossAudioTokenizerEncoderOutput, + MossAudioTokenizerOutput, +) + +__all__ = [ + "MossAudioTokenizer", + "MossAudioTokenizerConfig", + "MossAudioTokenizerDecoderOutput", + "MossAudioTokenizerEncoderOutput", + "MossAudioTokenizerModuleConfig", + "MossAudioTokenizerOutput", + "MossAudioTokenizerQuantizerConfig", + "load_moss_audio_tokenizer_config", +] diff --git a/mlx_audio/codec/models/moss_audio_tokenizer/config.py b/mlx_audio/codec/models/moss_audio_tokenizer/config.py new file mode 100644 index 000000000..7fbdafe5c --- /dev/null +++ b/mlx_audio/codec/models/moss_audio_tokenizer/config.py @@ -0,0 +1,206 @@ +"""Configuration helpers for the MOSS audio tokenizer MLX port.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, List, Optional + +LEGACY_MODEL_TYPES = {"speech_tokenizer", "moss-audio-tokenizer"} +CANONICAL_MODEL_TYPE = "moss_audio_tokenizer" + + +@dataclass(frozen=True) +class MossAudioTokenizerModuleConfig: + module_type: str + patch_size: Optional[int] = None + input_dimension: Optional[int] = None + output_dimension: Optional[int] = None + d_model: Optional[int] = None + num_heads: Optional[int] = None + num_layers: Optional[int] = None + dim_feedforward: Optional[int] = None + causal: Optional[bool] = None + norm: Optional[str] = None + positional_embedding: Optional[str] = None + max_period: Optional[float] = None + gating: Optional[str] = None + layer_scale: Optional[float] = None + conv_layout: Optional[bool] = None + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "MossAudioTokenizerModuleConfig": + return cls( + module_type=str(data["module_type"]), + patch_size=data.get("patch_size"), + input_dimension=data.get("input_dimension"), + output_dimension=data.get("output_dimension"), + d_model=data.get("d_model"), + num_heads=data.get("num_heads"), + num_layers=data.get("num_layers"), + dim_feedforward=data.get("dim_feedforward"), + causal=data.get("causal"), + norm=data.get("norm"), + positional_embedding=data.get("positional_embedding"), + max_period=data.get("max_period"), + gating=data.get("gating"), + layer_scale=data.get("layer_scale"), + conv_layout=data.get("conv_layout"), + ) + + def to_dict(self) -> Dict[str, Any]: + payload: Dict[str, Any] = {"module_type": self.module_type} + for field_name in [ + "patch_size", + "input_dimension", + "output_dimension", + "d_model", + "num_heads", + "num_layers", + "dim_feedforward", + "causal", + "norm", + "positional_embedding", + "max_period", + "gating", + "layer_scale", + "conv_layout", + ]: + value = getattr(self, field_name) + if value is not None: + payload[field_name] = value + return payload + + +@dataclass(frozen=True) +class MossAudioTokenizerQuantizerConfig: + input_dim: int + rvq_dim: int + output_dim: int + num_quantizers: int + codebook_size: int + codebook_dim: int + quantizer_type: str + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "MossAudioTokenizerQuantizerConfig": + return cls( + input_dim=int(data["input_dim"]), + rvq_dim=int(data["rvq_dim"]), + output_dim=int(data["output_dim"]), + num_quantizers=int(data["num_quantizers"]), + codebook_size=int(data["codebook_size"]), + codebook_dim=int(data["codebook_dim"]), + quantizer_type=str(data["quantizer_type"]), + ) + + def to_dict(self) -> Dict[str, Any]: + return { + "input_dim": self.input_dim, + "rvq_dim": self.rvq_dim, + "output_dim": self.output_dim, + "num_quantizers": self.num_quantizers, + "codebook_size": self.codebook_size, + "codebook_dim": self.codebook_dim, + "quantizer_type": self.quantizer_type, + } + + +@dataclass(frozen=True) +class MossAudioTokenizerConfig: + model_type: str + sampling_rate: int + downsample_rate: int + causal_transformer_context_duration: float + encoder_modules: List[MossAudioTokenizerModuleConfig] + decoder_modules: List[MossAudioTokenizerModuleConfig] + quantizer: MossAudioTokenizerQuantizerConfig + + @property + def frame_rate(self) -> float: + return self.sampling_rate / self.downsample_rate + + @property + def encoder_patch_product(self) -> int: + return _patch_product(self.encoder_modules) + + @property + def decoder_patch_product(self) -> int: + return _patch_product(self.decoder_modules) + + def patch_alignment_is_valid(self) -> bool: + # Encoder and decoder patch products should both match configured downsample. + return ( + self.encoder_patch_product == self.downsample_rate + and self.decoder_patch_product == self.downsample_rate + ) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "MossAudioTokenizerConfig": + model_type = str(data.get("model_type", CANONICAL_MODEL_TYPE)) + if model_type in LEGACY_MODEL_TYPES: + model_type = CANONICAL_MODEL_TYPE + + encoder_payload = data.get("encoder_kwargs", data.get("encoder_modules", [])) + decoder_payload = data.get("decoder_kwargs", data.get("decoder_modules", [])) + encoder_modules = [ + MossAudioTokenizerModuleConfig.from_dict(item) for item in encoder_payload + ] + decoder_modules = [ + MossAudioTokenizerModuleConfig.from_dict(item) for item in decoder_payload + ] + quantizer_payload = data.get("quantizer_kwargs", data.get("quantizer")) + if quantizer_payload is None: + raise ValueError("Missing quantizer_kwargs/quantizer in config payload") + quantizer = MossAudioTokenizerQuantizerConfig.from_dict(quantizer_payload) + + return cls( + model_type=model_type, + sampling_rate=int(data["sampling_rate"]), + downsample_rate=int(data["downsample_rate"]), + causal_transformer_context_duration=float( + data["causal_transformer_context_duration"] + ), + encoder_modules=encoder_modules, + decoder_modules=decoder_modules, + quantizer=quantizer, + ) + + def to_dict(self) -> Dict[str, Any]: + return { + "model_type": self.model_type, + "sampling_rate": self.sampling_rate, + "downsample_rate": self.downsample_rate, + "causal_transformer_context_duration": self.causal_transformer_context_duration, + "encoder_kwargs": [module.to_dict() for module in self.encoder_modules], + "decoder_kwargs": [module.to_dict() for module in self.decoder_modules], + "quantizer_kwargs": self.quantizer.to_dict(), + "quantizer_type": self.quantizer.quantizer_type, + } + + +def _patch_product(modules: List[MossAudioTokenizerModuleConfig]) -> int: + product = 1 + for module in modules: + if module.module_type == "PatchedPretransform": + if module.patch_size is None: + raise ValueError("PatchedPretransform module is missing patch_size") + product *= int(module.patch_size) + return product + + +def load_moss_audio_tokenizer_config(path: str | Path) -> MossAudioTokenizerConfig: + config_path = Path(path) + data = json.loads(config_path.read_text(encoding="utf-8")) + return MossAudioTokenizerConfig.from_dict(data) + + +__all__ = [ + "CANONICAL_MODEL_TYPE", + "LEGACY_MODEL_TYPES", + "MossAudioTokenizerConfig", + "MossAudioTokenizerModuleConfig", + "MossAudioTokenizerQuantizerConfig", + "load_moss_audio_tokenizer_config", +] diff --git a/mlx_audio/codec/models/moss_audio_tokenizer/decoder.py b/mlx_audio/codec/models/moss_audio_tokenizer/decoder.py new file mode 100644 index 000000000..66da51ee4 --- /dev/null +++ b/mlx_audio/codec/models/moss_audio_tokenizer/decoder.py @@ -0,0 +1,48 @@ +"""Decoder module construction for the MOSS audio tokenizer.""" + +from __future__ import annotations + +import mlx.nn as nn + +from .config import MossAudioTokenizerConfig +from .modules import ( + MossAudioTokenizerPatchedPretransform, + MossAudioTokenizerProjectedTransformer, + MossAudioTokenizerTransformerConfig, +) + + +def build_moss_audio_tokenizer_decoder_modules( + config: MossAudioTokenizerConfig, +) -> list[nn.Module]: + current_frame_rate = float(config.sampling_rate) / float(config.downsample_rate) + modules: list[nn.Module] = [] + + for module_config in config.decoder_modules: + if module_config.module_type == "PatchedPretransform": + if module_config.patch_size is None: + raise ValueError("PatchedPretransform in decoder is missing patch_size") + module = MossAudioTokenizerPatchedPretransform( + patch_size=int(module_config.patch_size), + is_downsample=False, + module_type=module_config.module_type, + ) + elif module_config.module_type == "Transformer": + transformer_config = MossAudioTokenizerTransformerConfig.from_module_config( + module_config, + context=int( + current_frame_rate * config.causal_transformer_context_duration + ), + ) + module = MossAudioTokenizerProjectedTransformer( + transformer_config, module_type=module_config.module_type + ) + else: + raise ValueError( + f"Unsupported decoder module_type: {module_config.module_type}" + ) + + modules.append(module) + current_frame_rate *= int(getattr(module, "downsample_ratio", 1)) + + return modules diff --git a/mlx_audio/codec/models/moss_audio_tokenizer/encoder.py b/mlx_audio/codec/models/moss_audio_tokenizer/encoder.py new file mode 100644 index 000000000..06ac8fd67 --- /dev/null +++ b/mlx_audio/codec/models/moss_audio_tokenizer/encoder.py @@ -0,0 +1,48 @@ +"""Encoder module construction for the MOSS audio tokenizer.""" + +from __future__ import annotations + +import mlx.nn as nn + +from .config import MossAudioTokenizerConfig +from .modules import ( + MossAudioTokenizerPatchedPretransform, + MossAudioTokenizerProjectedTransformer, + MossAudioTokenizerTransformerConfig, +) + + +def build_moss_audio_tokenizer_encoder_modules( + config: MossAudioTokenizerConfig, +) -> list[nn.Module]: + current_frame_rate = float(config.sampling_rate) + modules: list[nn.Module] = [] + + for module_config in config.encoder_modules: + if module_config.module_type == "PatchedPretransform": + if module_config.patch_size is None: + raise ValueError("PatchedPretransform in encoder is missing patch_size") + module = MossAudioTokenizerPatchedPretransform( + patch_size=int(module_config.patch_size), + is_downsample=True, + module_type=module_config.module_type, + ) + elif module_config.module_type == "Transformer": + transformer_config = MossAudioTokenizerTransformerConfig.from_module_config( + module_config, + context=int( + current_frame_rate * config.causal_transformer_context_duration + ), + ) + module = MossAudioTokenizerProjectedTransformer( + transformer_config, module_type=module_config.module_type + ) + else: + raise ValueError( + f"Unsupported encoder module_type: {module_config.module_type}" + ) + + modules.append(module) + current_frame_rate /= int(getattr(module, "downsample_ratio", 1)) + + return modules diff --git a/mlx_audio/codec/models/moss_audio_tokenizer/model.py b/mlx_audio/codec/models/moss_audio_tokenizer/model.py new file mode 100644 index 000000000..e85784951 --- /dev/null +++ b/mlx_audio/codec/models/moss_audio_tokenizer/model.py @@ -0,0 +1,861 @@ +"""MLX implementation of the MOSS audio tokenizer codec.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Optional, Union + +import mlx.core as mx +import mlx.nn as nn +from huggingface_hub import snapshot_download +from mlx.utils import tree_flatten + +from .config import MossAudioTokenizerConfig, load_moss_audio_tokenizer_config +from .decoder import build_moss_audio_tokenizer_decoder_modules +from .encoder import build_moss_audio_tokenizer_encoder_modules +from .quantizer import ( + MossAudioTokenizerResidualLFQ, + MossAudioTokenizerResidualVQ, + build_moss_audio_tokenizer_quantizer, +) + + +@dataclass +class MossAudioTokenizerEncoderOutput: + audio_codes: Optional[mx.array] = None + audio_codes_lengths: Optional[mx.array] = None + encoder_hidden_states: Optional[mx.array] = None + + +@dataclass +class MossAudioTokenizerDecoderOutput: + audio: Optional[mx.array] = None + audio_lengths: Optional[mx.array] = None + + +@dataclass +class MossAudioTokenizerOutput: + audio: Optional[mx.array] = None + audio_lengths: Optional[mx.array] = None + audio_codes: Optional[mx.array] = None + audio_codes_lengths: Optional[mx.array] = None + + +class MossAudioTokenizer(nn.Module): + """Standalone codec used by the MOSS-TTS family.""" + + def __init__(self, config: MossAudioTokenizerConfig): + super().__init__() + self.config = config + self.sampling_rate = config.sampling_rate + self.downsample_rate = config.downsample_rate + self.causal_transformer_context_duration = ( + config.causal_transformer_context_duration + ) + + self.encoder = build_moss_audio_tokenizer_encoder_modules(config) + self.quantizer = build_moss_audio_tokenizer_quantizer(config.quantizer) + self.decoder = build_moss_audio_tokenizer_decoder_modules(config) + + @property + def frame_rate(self) -> float: + return self.sampling_rate / self.downsample_rate + + def _build_caches(self, modules: list[nn.Module]) -> list[Optional[list]]: + caches: list[Optional[list]] = [] + for module in modules: + make_cache = getattr(module, "make_cache", None) + caches.append(make_cache() if callable(make_cache) else None) + return caches + + def _ensure_audio_layout(self, input_values: mx.array) -> mx.array: + if input_values.ndim == 1: + input_values = input_values[None, None, :] + elif input_values.ndim == 2: + input_values = input_values[:, None, :] + elif input_values.ndim != 3: + raise ValueError( + f"Expected input_values with 1/2/3 dims, got shape={input_values.shape}" + ) + return input_values + + def _resolve_requested_quantizers(self, num_quantizers: Optional[int]) -> int: + configured_quantizers = self.config.quantizer.num_quantizers + if num_quantizers is None: + return configured_quantizers + if num_quantizers <= 0: + raise ValueError("num_quantizers must be > 0 when provided.") + if num_quantizers > configured_quantizers: + raise ValueError( + f"num_quantizers ({num_quantizers}) must be <= configured " + f"quantizer count ({configured_quantizers})." + ) + return int(num_quantizers) + + def _normalize_decode_audio_codes( + self, + audio_codes: mx.array, + *, + num_quantizers: Optional[int], + ) -> mx.array: + configured_quantizers = self.config.quantizer.num_quantizers + requested_quantizers = self._resolve_requested_quantizers(num_quantizers) + + if audio_codes.ndim not in {2, 3}: + raise ValueError( + f"Expected audio_codes with 2 or 3 dims, got shape={audio_codes.shape}" + ) + + candidates: list[tuple[str, int, mx.array]] = [] + + def register_candidate( + orientation: str, + quantizer_count: int, + normalized: mx.array, + ) -> None: + candidates.append((orientation, quantizer_count, normalized)) + + if audio_codes.ndim == 2: + if audio_codes.shape[0] == configured_quantizers: + register_candidate( + "NQ-first", + configured_quantizers, + audio_codes[:, None, :], + ) + if ( + requested_quantizers != configured_quantizers + and audio_codes.shape[0] == requested_quantizers + ): + register_candidate( + "NQ-first", + requested_quantizers, + audio_codes[:, None, :], + ) + if audio_codes.shape[1] == configured_quantizers: + register_candidate( + "NQ-last", + configured_quantizers, + audio_codes.transpose(1, 0)[:, None, :], + ) + if ( + requested_quantizers != configured_quantizers + and audio_codes.shape[1] == requested_quantizers + ): + register_candidate( + "NQ-last", + requested_quantizers, + audio_codes.transpose(1, 0)[:, None, :], + ) + else: + if audio_codes.shape[0] == configured_quantizers: + register_candidate("NQ-first", configured_quantizers, audio_codes) + if ( + requested_quantizers != configured_quantizers + and audio_codes.shape[0] == requested_quantizers + ): + register_candidate("NQ-first", requested_quantizers, audio_codes) + if audio_codes.shape[-1] == configured_quantizers: + register_candidate( + "NQ-last", + configured_quantizers, + audio_codes.transpose(2, 0, 1), + ) + if ( + requested_quantizers != configured_quantizers + and audio_codes.shape[-1] == requested_quantizers + ): + register_candidate( + "NQ-last", + requested_quantizers, + audio_codes.transpose(2, 0, 1), + ) + + if not candidates: + shape_contract = ( + "Expected (NQ, T)/(T, NQ) or (NQ, B, T)/(B, T, NQ) with " + f"NQ in {{{configured_quantizers}" + + ( + f", {requested_quantizers}" + if requested_quantizers != configured_quantizers + else "" + ) + + "}." + ) + raise ValueError( + f"Unrecognized audio_codes layout for shape={audio_codes.shape}. " + f"{shape_contract}" + ) + + if len(candidates) > 1: + if ( + num_quantizers is not None + and requested_quantizers != configured_quantizers + ): + # Only prefix requests should override canonical tie resolution. + # For no-op requests (requested == configured), preserve implicit + # canonical behavior so explicit num_quantizers does not transpose + # canonical decode inputs. + requested_candidates = [ + candidate + for candidate in candidates + if candidate[1] == requested_quantizers + ] + if len(requested_candidates) == 1: + candidates = requested_candidates + elif len(requested_candidates) > 1: + requested_nq_first = [ + candidate + for candidate in requested_candidates + if candidate[0] == "NQ-first" + ] + requested_nq_last = [ + candidate + for candidate in requested_candidates + if candidate[0] == "NQ-last" + ] + + # Explicit square ties are ambiguous by shape alone. Keep canonical + # NQ-first orientation for these cases so canonical decode inputs + # do not get transposed under explicit num_quantizers requests. + is_square_requested_tie = False + is_canonical_prefix_tie = False + if audio_codes.ndim == 2: + is_square_requested_tie = ( + int(audio_codes.shape[0]) == requested_quantizers + and int(audio_codes.shape[1]) == requested_quantizers + ) + else: + is_square_requested_tie = ( + int(audio_codes.shape[0]) == requested_quantizers + and int(audio_codes.shape[1]) == 1 + and int(audio_codes.shape[-1]) == requested_quantizers + ) or ( + int(audio_codes.shape[0]) == 1 + and int(audio_codes.shape[1]) == requested_quantizers + and int(audio_codes.shape[-1]) == requested_quantizers + ) + # True-prefix 3D ties can still be canonical `(NQ, B, T)` inputs + # when both first/last dimensions match the requested prefix + # count. Preserve NQ-first in this family so we do not swap + # batch/time semantics into `(NQ, T, B)`. + is_canonical_prefix_tie = ( + int(audio_codes.shape[0]) == requested_quantizers + and int(audio_codes.shape[-1]) == requested_quantizers + and int(audio_codes.shape[1]) > 1 + ) + + if (is_square_requested_tie or is_canonical_prefix_tie) and len( + requested_nq_first + ) == 1: + candidates = requested_nq_first + elif len(requested_nq_last) == 1: + candidates = requested_nq_last + else: + candidates = requested_candidates + if len(candidates) > 1: + # Fallback tie-break: prefer canonical layout so encode() -> decode() + # round-trips remain valid when T == NQ. + canonical_candidates = [ + candidate for candidate in candidates if candidate[0] == "NQ-first" + ] + if len(canonical_candidates) == 1: + candidates = canonical_candidates + else: + candidate_labels = [ + f"{orientation}[nq={nq}]" for orientation, nq, _ in candidates + ] + raise ValueError( + "Ambiguous audio_codes layout. Multiple interpretations are valid for " + f"shape={audio_codes.shape}: {candidate_labels}. " + "Use a canonical layout to disambiguate." + ) + + _, source_quantizers, normalized = candidates[0] + if requested_quantizers > source_quantizers: + raise ValueError( + f"num_quantizers ({requested_quantizers}) must be <= decoded source " + f"quantizer count ({source_quantizers})." + ) + if requested_quantizers < source_quantizers: + normalized = normalized[:requested_quantizers] + return normalized + + def _encode_frame( + self, + input_values: mx.array, + input_lengths: Optional[mx.array] = None, + n_quantizers: Optional[int] = None, + caches: Optional[list[Optional[list]]] = None, + ) -> MossAudioTokenizerEncoderOutput: + input_values = self._ensure_audio_layout(input_values) + batch_size, _, time_steps = input_values.shape + + if input_lengths is None: + input_lengths = mx.full((batch_size,), time_steps, dtype=mx.int32) + else: + input_lengths = input_lengths.astype(mx.int32) + + if time_steps % self.downsample_rate != 0: + pad_length = self.downsample_rate - (time_steps % self.downsample_rate) + input_values = mx.pad(input_values, [(0, 0), (0, 0), (0, pad_length)]) + + hidden = input_values + hidden_lengths = input_lengths + for idx, module in enumerate(self.encoder): + cache = caches[idx] if caches is not None else None + hidden, hidden_lengths = module(hidden, hidden_lengths, cache=cache) + + quantizer = self.quantizer + if not isinstance( + quantizer, (MossAudioTokenizerResidualLFQ, MossAudioTokenizerResidualVQ) + ): + raise TypeError(f"Unsupported quantizer type: {type(quantizer)}") + _, audio_codes, audio_codes_lengths = quantizer( + hidden, hidden_lengths, n_quantizers + ) + + return MossAudioTokenizerEncoderOutput( + audio_codes=audio_codes.astype(mx.int32), + audio_codes_lengths=audio_codes_lengths.astype(mx.int32), + encoder_hidden_states=hidden, + ) + + def _decode_frame( + self, + audio_codes: mx.array, + audio_codes_lengths: Optional[mx.array] = None, + caches: Optional[list[Optional[list]]] = None, + ) -> MossAudioTokenizerDecoderOutput: + if audio_codes.ndim != 3: + raise ValueError( + "Expected canonical audio_codes layout (NQ, B, T) in _decode_frame, " + f"got shape={audio_codes.shape}." + ) + audio_codes = audio_codes.astype(mx.int32) + _, batch_size, time_steps = audio_codes.shape + + if audio_codes_lengths is None: + audio_codes_lengths = mx.full((batch_size,), time_steps, dtype=mx.int32) + else: + audio_codes_lengths = audio_codes_lengths.astype(mx.int32) + + quantizer = self.quantizer + if not isinstance( + quantizer, (MossAudioTokenizerResidualLFQ, MossAudioTokenizerResidualVQ) + ): + raise TypeError(f"Unsupported quantizer type: {type(quantizer)}") + decoded = quantizer.decode_codes(audio_codes) + + audio = decoded + audio_lengths = audio_codes_lengths + for idx, module in enumerate(self.decoder): + cache = caches[idx] if caches is not None else None + audio, audio_lengths = module(audio, audio_lengths, cache=cache) + + return MossAudioTokenizerDecoderOutput( + audio=audio, + audio_lengths=audio_lengths.astype(mx.int32), + ) + + def encode( + self, + input_values: mx.array, + padding_mask: Optional[mx.array] = None, + num_quantizers: Optional[int] = None, + return_dict: bool = True, + chunk_duration: Optional[float] = None, + ) -> MossAudioTokenizerEncoderOutput | tuple[mx.array, mx.array]: + input_values = self._ensure_audio_layout(input_values) + batch_size, _, time_steps = input_values.shape + + if padding_mask is not None: + input_lengths = mx.sum(padding_mask.astype(mx.int32), axis=-1).astype( + mx.int32 + ) + else: + input_lengths = mx.full((batch_size,), time_steps, dtype=mx.int32) + + if chunk_duration is None: + output = self._encode_frame( + input_values, input_lengths, n_quantizers=num_quantizers + ) + else: + if chunk_duration <= 0: + raise ValueError("chunk_duration must be > 0 when provided.") + if chunk_duration > self.causal_transformer_context_duration: + raise ValueError( + "chunk_duration must be <= " + f"{self.causal_transformer_context_duration}, got {chunk_duration}." + ) + if batch_size != 1: + raise ValueError( + "Streaming encode currently only supports batch_size=1." + ) + + chunk_length = int(round(chunk_duration * self.sampling_rate)) + if chunk_length <= 0: + raise ValueError( + "chunk_duration is too small and results in chunk_length <= 0." + ) + if chunk_length % self.downsample_rate != 0: + raise ValueError( + "chunk_duration * sampling_rate must be divisible by downsample_rate. " + f"Got chunk_length={chunk_length}, downsample_rate={self.downsample_rate}." + ) + + input_length = int(input_lengths[0]) + if input_length <= chunk_length: + output = self._encode_frame( + input_values[..., :input_length], + input_lengths, + n_quantizers=num_quantizers, + ) + else: + caches = self._build_caches(self.encoder) + codes_chunks = [] + hidden_chunks = [] + for start_idx in range(0, input_length, chunk_length): + input_length_i = min(chunk_length, input_length - start_idx) + if input_length_i <= 0: + break + input_values_i = input_values[ + ..., start_idx : start_idx + input_length_i + ] + input_lengths_i = mx.array([input_length_i], dtype=mx.int32) + chunk_output = self._encode_frame( + input_values_i, + input_lengths_i, + n_quantizers=num_quantizers, + caches=caches, + ) + if ( + chunk_output.audio_codes is None + or chunk_output.audio_codes_lengths is None + ): + raise RuntimeError( + "Internal error: _encode_frame returned empty audio codes." + ) + if chunk_output.encoder_hidden_states is None: + raise RuntimeError( + "Internal error: _encode_frame returned empty hidden states." + ) + length_i = int(chunk_output.audio_codes_lengths[0]) + codes_chunks.append(chunk_output.audio_codes[:, :, :length_i]) + hidden_chunks.append( + chunk_output.encoder_hidden_states[:, :, :length_i] + ) + + audio_codes = mx.concatenate(codes_chunks, axis=-1) + hidden_states = mx.concatenate(hidden_chunks, axis=-1) + output = MossAudioTokenizerEncoderOutput( + audio_codes=audio_codes, + audio_codes_lengths=mx.array( + [audio_codes.shape[-1]], dtype=mx.int32 + ), + encoder_hidden_states=hidden_states, + ) + + if not return_dict: + if output.audio_codes is None or output.audio_codes_lengths is None: + raise RuntimeError("encode() produced empty outputs.") + return output.audio_codes, output.audio_codes_lengths + return output + + def decode( + self, + audio_codes: mx.array, + padding_mask: Optional[mx.array] = None, + return_dict: bool = True, + chunk_duration: Optional[float] = None, + num_quantizers: Optional[int] = None, + ) -> MossAudioTokenizerDecoderOutput | tuple[mx.array]: + audio_codes = self._normalize_decode_audio_codes( + audio_codes, + num_quantizers=num_quantizers, + ).astype(mx.int32) + + _, batch_size, time_steps = audio_codes.shape + if padding_mask is not None: + codes_lengths = mx.sum(padding_mask.astype(mx.int32), axis=-1).astype( + mx.int32 + ) + else: + codes_lengths = mx.full((batch_size,), time_steps, dtype=mx.int32) + + if chunk_duration is None: + output = self._decode_frame(audio_codes, codes_lengths) + else: + if chunk_duration <= 0: + raise ValueError("chunk_duration must be > 0 when provided.") + if chunk_duration > self.causal_transformer_context_duration: + raise ValueError( + "chunk_duration must be <= " + f"{self.causal_transformer_context_duration}, got {chunk_duration}." + ) + if batch_size != 1: + raise ValueError( + "Streaming decode currently only supports batch_size=1." + ) + + chunk_length = int(round(chunk_duration * self.sampling_rate)) + if chunk_length <= 0: + raise ValueError( + "chunk_duration is too small and results in chunk_length <= 0." + ) + if chunk_length % self.downsample_rate != 0: + raise ValueError( + "chunk_duration * sampling_rate must be divisible by downsample_rate. " + f"Got chunk_length={chunk_length}, downsample_rate={self.downsample_rate}." + ) + chunk_frame_length = chunk_length // self.downsample_rate + + codes_length = int(codes_lengths[0]) + if codes_length <= chunk_frame_length: + output = self._decode_frame( + audio_codes[..., :codes_length], codes_lengths + ) + else: + caches = self._build_caches(self.decoder) + wav_chunks = [] + for start_idx in range(0, codes_length, chunk_frame_length): + codes_length_i = min(chunk_frame_length, codes_length - start_idx) + if codes_length_i <= 0: + break + codes_i = audio_codes[:, :, start_idx : start_idx + codes_length_i] + codes_lengths_i = mx.array([codes_length_i], dtype=mx.int32) + chunk_output = self._decode_frame( + codes_i, codes_lengths_i, caches=caches + ) + if chunk_output.audio is None or chunk_output.audio_lengths is None: + raise RuntimeError( + "Internal error: _decode_frame returned empty audio." + ) + wav_chunk = chunk_output.audio[ + :, :, : int(chunk_output.audio_lengths[0]) + ] + mx.eval(wav_chunk) + wav_chunks.append(wav_chunk) + mx.clear_cache() + wav = mx.concatenate(wav_chunks, axis=-1) + output = MossAudioTokenizerDecoderOutput( + audio=wav, + audio_lengths=mx.array([wav.shape[-1]], dtype=mx.int32), + ) + + if not return_dict: + if output.audio is None: + raise RuntimeError("decode() produced empty audio.") + return (output.audio,) + return output + + def batch_encode( + self, + wav_list: list[mx.array], + num_quantizers: Optional[int] = None, + ) -> MossAudioTokenizerEncoderOutput: + if not wav_list: + raise ValueError("wav_list must contain at least one waveform.") + + normalized = [] + for wav in wav_list: + if wav.ndim == 2: + if wav.shape[0] != 1: + raise ValueError( + "Expected 2D waveform shape (1, T), got shape=" f"{wav.shape}." + ) + wav = wav.squeeze(0) + if wav.ndim != 1: + raise ValueError( + f"Each waveform in wav_list must be 1D or (1, T), got {wav.shape}." + ) + normalized.append(wav) + + batch_size = len(normalized) + max_length = max(int(w.shape[-1]) for w in normalized) + input_values = mx.zeros((batch_size, 1, max_length), dtype=mx.float32) + input_lengths = mx.zeros((batch_size,), dtype=mx.int32) + + for idx, wav in enumerate(normalized): + length_i = int(wav.shape[-1]) + input_values[idx, 0, :length_i] = wav.astype(mx.float32) + input_lengths[idx] = length_i + + return self._encode_frame( + input_values, + input_lengths, + n_quantizers=num_quantizers, + ) + + def batch_decode( + self, + codes_list: list[mx.array], + num_quantizers: Optional[int] = None, + ) -> MossAudioTokenizerDecoderOutput: + if not codes_list: + raise ValueError("codes_list must contain at least one code tensor.") + + normalized = [] + for codes in codes_list: + normalized_codes = self._normalize_decode_audio_codes( + codes, + num_quantizers=num_quantizers, + ) + if int(normalized_codes.shape[1]) != 1: + raise ValueError( + "batch_decode() expects each codes_list entry to resolve to " + f"batch_size=1, got batch_size={int(normalized_codes.shape[1])} " + f"for shape={codes.shape}. Use decode() for batched code tensors." + ) + normalized.append(normalized_codes.squeeze(1)) + target_quantizers = int(normalized[0].shape[0]) + if any(int(c.shape[0]) != target_quantizers for c in normalized): + raise ValueError( + "All elements in codes_list must resolve to the same quantizer count." + ) + + max_length = max(int(c.shape[-1]) for c in normalized) + batch_size = len(normalized) + audio_codes = mx.zeros( + (target_quantizers, batch_size, max_length), + dtype=mx.int32, + ) + audio_codes_lengths = mx.zeros((batch_size,), dtype=mx.int32) + + for idx, codes in enumerate(normalized): + time_steps = int(codes.shape[-1]) + audio_codes[:, idx, :time_steps] = codes + audio_codes_lengths[idx] = time_steps + + return self._decode_frame(audio_codes, audio_codes_lengths) + + def streaming_decode( + self, + audio_codes: mx.array, + *, + chunk_tokens: int = 100, + num_quantizers: Optional[int] = None, + ): + if chunk_tokens <= 0: + raise ValueError("chunk_tokens must be > 0.") + + audio_codes = self._normalize_decode_audio_codes( + audio_codes, + num_quantizers=num_quantizers, + ).astype(mx.int32) + + _, batch_size, total_tokens = audio_codes.shape + if batch_size != 1: + raise ValueError("streaming_decode currently only supports batch_size=1.") + + caches = self._build_caches(self.decoder) + for start_idx in range(0, total_tokens, chunk_tokens): + end_idx = min(start_idx + chunk_tokens, total_tokens) + codes_chunk = audio_codes[:, :, start_idx:end_idx] + chunk_len = int(codes_chunk.shape[-1]) + output = self._decode_frame( + codes_chunk, + mx.array([chunk_len], dtype=mx.int32), + caches=caches, + ) + if output.audio is None or output.audio_lengths is None: + raise RuntimeError( + "Internal error: _decode_frame returned empty audio." + ) + wav_chunk = output.audio[:, :, : int(output.audio_lengths[0])] + mx.eval(wav_chunk) + yield wav_chunk + mx.clear_cache() + + def __call__( + self, + input_values: Optional[mx.array] = None, + padding_mask: Optional[mx.array] = None, + audio_codes: Optional[mx.array] = None, + num_quantizers: Optional[int] = None, + return_dict: bool = True, + ) -> ( + MossAudioTokenizerOutput + | tuple[Optional[mx.array], Optional[mx.array], Optional[mx.array]] + ): + output_audio_codes = None + output_audio_codes_lengths = None + output_audio = None + output_audio_lengths = None + decoded_from_encoded_codes = False + + if input_values is not None: + encode_output = self.encode( + input_values, + padding_mask=padding_mask, + num_quantizers=num_quantizers, + return_dict=True, + ) + if not isinstance(encode_output, MossAudioTokenizerEncoderOutput): + raise RuntimeError( + "Internal error: encode() returned unexpected output type." + ) + output_audio_codes = encode_output.audio_codes + output_audio_codes_lengths = encode_output.audio_codes_lengths + + if audio_codes is None: + audio_codes = output_audio_codes + decoded_from_encoded_codes = True + + if audio_codes is not None: + if decoded_from_encoded_codes and output_audio_codes_lengths is not None: + decode_output = self._decode_frame( + audio_codes, output_audio_codes_lengths + ) + else: + decode_output = self.decode( + audio_codes, + padding_mask=padding_mask, + return_dict=True, + num_quantizers=num_quantizers, + ) + if not isinstance(decode_output, MossAudioTokenizerDecoderOutput): + raise RuntimeError( + "Internal error: decode() returned unexpected output type." + ) + output_audio = decode_output.audio + output_audio_lengths = decode_output.audio_lengths + + if not return_dict: + return output_audio_codes, output_audio, output_audio_lengths + + return MossAudioTokenizerOutput( + audio=output_audio, + audio_lengths=output_audio_lengths, + audio_codes=output_audio_codes, + audio_codes_lengths=output_audio_codes_lengths, + ) + + def model_quant_predicate(self, path: str, module) -> bool: + # Protect codebook embeddings from quantization. + if isinstance(module, nn.Embedding): + return False + return True + + def _sanitize_chunk( + self, + weights: Dict[str, mx.array], + pending_weight_norm: Optional[Dict[str, Dict[str, mx.array]]] = None, + ) -> tuple[Dict[str, mx.array], Dict[str, Dict[str, mx.array]]]: + pending = {} if pending_weight_norm is None else dict(pending_weight_norm) + sanitized: Dict[str, mx.array] = {} + current_shapes = { + name: tuple(value.shape) for name, value in tree_flatten(self.parameters()) + } + + for key, value in weights.items(): + if key.endswith(".parametrizations.weight.original0"): + base = key[: -len(".parametrizations.weight.original0")] + entry = pending.setdefault(base, {}) + entry["g"] = value + continue + if key.endswith(".parametrizations.weight.original1"): + base = key[: -len(".parametrizations.weight.original1")] + entry = pending.setdefault(base, {}) + entry["v"] = value + continue + + new_key = key + if value.ndim == 3 and new_key in current_shapes: + if tuple(value.shape) != current_shapes[new_key]: + value = value.swapaxes(1, 2) + sanitized[new_key] = value + + resolved = [] + for base, parts in pending.items(): + if "g" not in parts or "v" not in parts: + continue + g = parts["g"].astype(mx.float32) + v = parts["v"].astype(mx.float32) + reduce_axes = tuple(range(1, v.ndim)) + norm = mx.sqrt(mx.sum(v**2, axis=reduce_axes, keepdims=True) + 1e-12) + merged = (g * v / norm).astype(parts["v"].dtype) + target_key = f"{base}.weight" + if merged.ndim == 3 and target_key in current_shapes: + if tuple(merged.shape) != current_shapes[target_key]: + merged = merged.swapaxes(1, 2) + sanitized[target_key] = merged + resolved.append(base) + + for base in resolved: + pending.pop(base, None) + + return sanitized, pending + + def sanitize(self, weights: Dict[str, mx.array]) -> Dict[str, mx.array]: + sanitized, pending = self._sanitize_chunk(weights, pending_weight_norm=None) + if pending: + missing = sorted(pending.keys()) + raise ValueError( + "Missing weight-norm pairs while sanitizing weights for keys: " + f"{missing}" + ) + return sanitized + + @classmethod + def from_pretrained( + cls, + path_or_repo: Union[str, Path], + *, + strict: bool = True, + ) -> "MossAudioTokenizer": + model_path = Path(path_or_repo) + if not model_path.exists(): + model_path = Path( + snapshot_download( + str(path_or_repo), + allow_patterns=["*.json", "*.safetensors"], + ) + ) + + config_path = model_path / "config.json" + if not config_path.exists(): + raise FileNotFoundError(f"Missing config.json at {model_path}") + config = load_moss_audio_tokenizer_config(config_path) + model = cls(config) + + weight_files = sorted(model_path.glob("*.safetensors")) + if not weight_files: + raise FileNotFoundError(f"No *.safetensors files found at {model_path}") + + loaded_keys = set() + pending: Dict[str, Dict[str, mx.array]] = {} + for weight_file in weight_files: + shard_weights = mx.load(weight_file.as_posix(), format="safetensors") + sanitized, pending = model._sanitize_chunk( + shard_weights, pending_weight_norm=pending + ) + if sanitized: + model.load_weights(list(sanitized.items()), strict=False) + loaded_keys.update(sanitized.keys()) + + if pending: + unresolved = sorted(pending.keys()) + raise ValueError( + "Unresolved weight-norm parameter groups across shards: " + f"{unresolved}" + ) + + if strict: + expected_keys = {name for name, _ in tree_flatten(model.parameters())} + missing = sorted(expected_keys - loaded_keys) + if missing: + raise ValueError( + "Strict load failed: missing parameter weights for keys: " + f"{missing[:20]}" + ("..." if len(missing) > 20 else "") + ) + + mx.eval(model.parameters()) + return model + + def save_config(self, path: Union[str, Path]) -> None: + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as handle: + json.dump(self.config.to_dict(), handle, indent=2, sort_keys=True) diff --git a/mlx_audio/codec/models/moss_audio_tokenizer/modules.py b/mlx_audio/codec/models/moss_audio_tokenizer/modules.py new file mode 100644 index 000000000..22005b4d2 --- /dev/null +++ b/mlx_audio/codec/models/moss_audio_tokenizer/modules.py @@ -0,0 +1,424 @@ +"""Core modules for the MOSS audio tokenizer codec. + +This file intentionally mirrors upstream module naming where practical so +checkpoint weights can load with minimal remapping. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional, Sequence + +import mlx.core as mx +import mlx.nn as nn +from mlx_lm.models.cache import KVCache, RotatingKVCache + +from .config import MossAudioTokenizerModuleConfig + + +@dataclass(frozen=True) +class MossAudioTokenizerTransformerConfig: + input_dimension: int + output_dimension: int + d_model: int + num_heads: int + num_layers: int + dim_feedforward: int + causal: bool = True + norm: str = "layer_norm" + positional_embedding: str = "rope" + max_period: float = 10000.0 + gating: str = "none" + layer_scale: Optional[float] = None + conv_layout: bool = True + context: Optional[int] = None + + @classmethod + def from_module_config( + cls, + module_config: MossAudioTokenizerModuleConfig, + *, + context: Optional[int], + ) -> "MossAudioTokenizerTransformerConfig": + if module_config.module_type != "Transformer": + raise ValueError( + f"Expected Transformer module config, got {module_config.module_type}" + ) + required_int_fields = { + "input_dimension": module_config.input_dimension, + "output_dimension": module_config.output_dimension, + "d_model": module_config.d_model, + "num_heads": module_config.num_heads, + "num_layers": module_config.num_layers, + } + for field_name, value in required_int_fields.items(): + if value is None: + raise ValueError( + f"Transformer module config is missing {field_name}: {module_config}" + ) + + dim_feedforward = module_config.dim_feedforward + if dim_feedforward is None: + raise ValueError( + "Transformer module config is missing dim_feedforward: " + f"{module_config}" + ) + + return cls( + input_dimension=int(module_config.input_dimension), + output_dimension=int(module_config.output_dimension), + d_model=int(module_config.d_model), + num_heads=int(module_config.num_heads), + num_layers=int(module_config.num_layers), + dim_feedforward=int(dim_feedforward), + causal=bool( + module_config.causal if module_config.causal is not None else True + ), + norm=str( + module_config.norm if module_config.norm is not None else "layer_norm" + ), + positional_embedding=str( + module_config.positional_embedding + if module_config.positional_embedding is not None + else "rope" + ), + max_period=float( + module_config.max_period + if module_config.max_period is not None + else 10000 + ), + gating=str( + module_config.gating if module_config.gating is not None else "none" + ), + layer_scale=( + float(module_config.layer_scale) + if module_config.layer_scale is not None + else None + ), + conv_layout=bool( + module_config.conv_layout + if module_config.conv_layout is not None + else True + ), + context=context, + ) + + @property + def head_dim(self) -> int: + if self.d_model % self.num_heads != 0: + raise ValueError( + f"d_model ({self.d_model}) must be divisible by num_heads ({self.num_heads})" + ) + return self.d_model // self.num_heads + + +class MossAudioTokenizerLayerScale(nn.Module): + """Per-channel learned residual scaling.""" + + def __init__(self, channels: int, init: float): + super().__init__() + self.scale = mx.ones((channels,)) * init + + def __call__(self, x: mx.array) -> mx.array: + return x * self.scale + + +def _create_norm(norm_type: str, dim: int) -> nn.Module: + if norm_type == "layer_norm": + return nn.LayerNorm(dim, eps=1e-5) + if norm_type == "rms_norm": + return nn.RMSNorm(dim, eps=1e-8) + raise ValueError(f"Unsupported norm type: {norm_type}") + + +def _apply_weights_per_step( + modules: Sequence[nn.Module], + schedule: Optional[list[int]], + x: mx.array, + offset: int, +) -> mx.array: + if len(modules) == 1: + return modules[0](x) + + outputs = [] + _, time_steps, _ = x.shape + for step in range(time_steps): + module_index = step + offset + if schedule is not None: + if module_index < 0 or module_index >= len(schedule): + raise ValueError( + "weights_per_step_schedule is too short for " + f"module_index={module_index}." + ) + module_index = schedule[module_index] + if module_index < 0 or module_index >= len(modules): + raise ValueError( + f"module_index={module_index} is out of range for {len(modules)} modules." + ) + outputs.append(modules[module_index](x[:, step : step + 1])) + return mx.concatenate(outputs, axis=1) + + +class MossAudioTokenizerMultiheadAttention(nn.Module): + """Causal MHA with optional RoPE and bounded KV cache context.""" + + def __init__(self, config: MossAudioTokenizerTransformerConfig): + super().__init__() + self.embed_dim = config.d_model + self.num_heads = config.num_heads + self.head_dim = config.head_dim + self.scale = self.head_dim**-0.5 + self.causal = config.causal + self.context = config.context + self.weights_per_step_schedule: Optional[list[int]] = None + + # Kept as lists to match upstream checkpoint key names: + # self_attn.in_projs.0.weight and self_attn.out_projs.0.weight + self.in_projs = [nn.Linear(self.embed_dim, 3 * self.embed_dim, bias=False)] + self.out_projs = [nn.Linear(self.embed_dim, self.embed_dim, bias=False)] + + self.rope = None + if config.positional_embedding in {"rope", "sin_rope"}: + self.rope = nn.RoPE(self.head_dim, traditional=True, base=config.max_period) + + def __call__( + self, + x: mx.array, + cache: Optional[KVCache | RotatingKVCache] = None, + mask: Optional[mx.array] = None, + ) -> mx.array: + batch_size, time_steps, hidden_dim = x.shape + if hidden_dim != self.embed_dim: + raise ValueError(f"Expected hidden dim {self.embed_dim}, got {hidden_dim}") + + offset = 0 if cache is None else int(cache.offset) + projected = _apply_weights_per_step( + self.in_projs, + self.weights_per_step_schedule, + x, + offset, + ) + projected = projected.reshape( + batch_size, time_steps, 3, self.num_heads, self.head_dim + ).transpose(2, 0, 3, 1, 4) + + q = projected[0] + k = projected[1] + v = projected[2] + + if self.rope is not None: + q = self.rope(q, offset=offset) + k = self.rope(k, offset=offset) + + if cache is not None: + k, v = cache.update_and_fetch(k, v) + + if mask is None and self.causal: + key_steps = k.shape[2] + key_offset = 0 if cache is None else int(cache.offset) - key_steps + key_positions = mx.arange(key_steps, dtype=mx.int32) + key_offset + query_positions = mx.arange(time_steps, dtype=mx.int32) + offset + delta = query_positions[:, None] - key_positions[None, :] + allowed = (key_positions[None, :] >= 0) & (delta >= 0) + if self.context is not None: + allowed = allowed & (delta < self.context) + mask = mx.where(allowed, 0.0, -1e9).astype(x.dtype) + mask = mask[None, None, :, :] + + attended = mx.fast.scaled_dot_product_attention( + q, + k, + v, + scale=self.scale, + mask=mask, + ) + attended = attended.transpose(0, 2, 1, 3).reshape(batch_size, time_steps, -1) + return _apply_weights_per_step( + self.out_projs, + self.weights_per_step_schedule, + attended, + offset, + ) + + +class MossAudioTokenizerTransformerLayer(nn.Module): + """Transformer block aligned to the upstream module contract.""" + + def __init__(self, config: MossAudioTokenizerTransformerConfig): + super().__init__() + if config.gating != "none": + raise ValueError( + f"Unsupported gating mode for MOSS audio tokenizer: {config.gating}" + ) + + self.self_attn = MossAudioTokenizerMultiheadAttention(config) + self.norm1 = _create_norm(config.norm, config.d_model) + self.norm2 = _create_norm(config.norm, config.d_model) + self.linear1 = nn.Linear(config.d_model, config.dim_feedforward, bias=False) + self.linear2 = nn.Linear(config.dim_feedforward, config.d_model, bias=False) + + if config.layer_scale is None: + self.layer_scale_1 = nn.Identity() + self.layer_scale_2 = nn.Identity() + else: + self.layer_scale_1 = MossAudioTokenizerLayerScale( + config.d_model, config.layer_scale + ) + self.layer_scale_2 = MossAudioTokenizerLayerScale( + config.d_model, config.layer_scale + ) + + def __call__( + self, + x: mx.array, + cache: Optional[KVCache | RotatingKVCache] = None, + mask: Optional[mx.array] = None, + ) -> mx.array: + attn_update = self.self_attn(self.norm1(x), cache=cache, mask=mask) + x = x + self.layer_scale_1(attn_update) + mlp_update = self.linear2(nn.gelu_approx(self.linear1(self.norm2(x)))) + return x + self.layer_scale_2(mlp_update) + + +class MossAudioTokenizerTransformer(nn.Module): + """Stacked transformer with per-layer KV caches.""" + + def __init__(self, config: MossAudioTokenizerTransformerConfig): + super().__init__() + self.config = config + self.layers = [ + MossAudioTokenizerTransformerLayer(config) for _ in range(config.num_layers) + ] + + def make_cache(self) -> list[KVCache | RotatingKVCache]: + if self.config.context is None: + return [KVCache() for _ in self.layers] + return [ + RotatingKVCache(max_size=self.config.context, keep=0) for _ in self.layers + ] + + def __call__( + self, + x: mx.array, + cache: Optional[list[KVCache | RotatingKVCache]] = None, + mask: Optional[mx.array] = None, + ) -> mx.array: + if cache is None: + per_layer_cache: list[Optional[KVCache | RotatingKVCache]] = [ + None for _ in self.layers + ] + else: + if len(cache) != len(self.layers): + raise ValueError( + "Cache depth mismatch: expected " + f"{len(self.layers)} layers, got {len(cache)}." + ) + per_layer_cache = list(cache) + + for layer, layer_cache in zip(self.layers, per_layer_cache): + x = layer(x, cache=layer_cache, mask=mask) + return x + + +class MossAudioTokenizerProjectedTransformer(nn.Module): + """Transformer block with optional input/output projections.""" + + def __init__( + self, + config: MossAudioTokenizerTransformerConfig, + *, + module_type: str = "Transformer", + ): + super().__init__() + self.module_type = module_type + self.downsample_ratio = 1 + self.conv_layout = config.conv_layout + self.input_dimension = config.input_dimension + self.output_dimension = config.output_dimension + + if config.input_dimension == config.d_model: + self.input_proj = None + else: + self.input_proj = nn.Linear( + config.input_dimension, config.d_model, bias=False + ) + self.transformer = MossAudioTokenizerTransformer(config) + if config.output_dimension == config.d_model: + self.output_proj = None + else: + self.output_proj = nn.Linear( + config.d_model, config.output_dimension, bias=False + ) + + def make_cache(self) -> list[KVCache | RotatingKVCache]: + return self.transformer.make_cache() + + def __call__( + self, + x: mx.array, + input_lengths: mx.array, + cache: Optional[list[KVCache | RotatingKVCache]] = None, + mask: Optional[mx.array] = None, + ) -> tuple[mx.array, mx.array]: + if self.conv_layout: + x = x.swapaxes(1, 2) + if self.input_proj is not None: + x = self.input_proj(x) + x = self.transformer(x, cache=cache, mask=mask) + if self.output_proj is not None: + x = self.output_proj(x) + if self.conv_layout: + x = x.swapaxes(1, 2) + return x, input_lengths + + +class MossAudioTokenizerPatchedPretransform(nn.Module): + """Patch/unpatch module used for deterministic stride changes.""" + + def __init__( + self, + patch_size: int, + is_downsample: bool, + *, + module_type: str = "PatchedPretransform", + ): + super().__init__() + self.patch_size = int(patch_size) + self.downsample_ratio = self.patch_size + self.is_downsample = is_downsample + self.module_type = module_type + + def encode(self, x: mx.array, input_lengths: mx.array) -> tuple[mx.array, mx.array]: + batch_size, channels, _ = x.shape + patch = self.patch_size + x = ( + x.reshape(batch_size, channels, -1, patch) + .transpose(0, 1, 3, 2) + .reshape(batch_size, channels * patch, -1) + ) + output_lengths = input_lengths // patch + return x, output_lengths + + def decode(self, x: mx.array, input_lengths: mx.array) -> tuple[mx.array, mx.array]: + batch_size, channels_times_patch, length = x.shape + patch = self.patch_size + channels = channels_times_patch // patch + x = ( + x.reshape(batch_size, channels, patch, length) + .transpose(0, 1, 3, 2) + .reshape(batch_size, channels, length * patch) + ) + output_lengths = input_lengths * patch + return x, output_lengths + + def __call__( + self, + x: mx.array, + input_lengths: mx.array, + cache=None, + mask=None, + ) -> tuple[mx.array, mx.array]: + del cache, mask + if self.is_downsample: + return self.encode(x, input_lengths) + return self.decode(x, input_lengths) diff --git a/mlx_audio/codec/models/moss_audio_tokenizer/quantizer.py b/mlx_audio/codec/models/moss_audio_tokenizer/quantizer.py new file mode 100644 index 000000000..619a48dc3 --- /dev/null +++ b/mlx_audio/codec/models/moss_audio_tokenizer/quantizer.py @@ -0,0 +1,310 @@ +"""Quantizer modules for the MOSS audio tokenizer.""" + +from __future__ import annotations + +from typing import Optional + +import mlx.core as mx +import mlx.nn as nn + +from mlx_audio.codec.models.mimi.modules.conv import Conv1d + +from .config import MossAudioTokenizerQuantizerConfig + + +def _l2_normalize(x: mx.array, axis: int) -> mx.array: + norm = mx.sqrt(mx.sum(x.astype(mx.float32) ** 2, axis=axis, keepdims=True) + 1e-12) + return x / norm + + +def _mask_from_lengths(lengths: mx.array, max_time: int, dtype) -> mx.array: + mask = mx.arange(max_time, dtype=lengths.dtype)[None, :] < lengths[:, None] + return mask.astype(dtype)[:, None, :] + + +class MossAudioTokenizerVectorQuantize(nn.Module): + """Single RVQ codebook (inference-only).""" + + def __init__( + self, + input_dim: int, + codebook_size: int, + codebook_dim: int, + ): + super().__init__() + self.input_dim = input_dim + self.codebook_size = codebook_size + self.codebook_dim = codebook_dim + + if input_dim != codebook_dim: + self.in_proj = Conv1d(input_dim, codebook_dim, 1, bias=True) + self.out_proj = Conv1d(codebook_dim, input_dim, 1, bias=True) + else: + self.in_proj = None + self.out_proj = None + + self.codebook = nn.Embedding(codebook_size, codebook_dim) + + def decode_code(self, indices: mx.array) -> mx.array: + # (B, T) -> (B, D, T) + quantized = self.codebook(indices).swapaxes(1, 2).astype(mx.float32) + if self.out_proj is not None: + quantized = self.out_proj(quantized) + return quantized.astype(mx.float32) + + def __call__(self, z: mx.array) -> tuple[mx.array, mx.array, mx.array]: + z = z.astype(mx.float32) + z_e = self.in_proj(z) if self.in_proj is not None else z + encodings = z_e.swapaxes(1, 2).reshape(-1, z_e.shape[1]).astype(mx.float32) + codebook = self.codebook.weight.astype(mx.float32) + + distances = ( + mx.sum(encodings**2, axis=1, keepdims=True) + - 2.0 * (encodings @ codebook.transpose()) + + mx.sum(codebook**2, axis=1)[None, :] + ) + indices = mx.argmax(-distances, axis=1).reshape(z.shape[0], -1).astype(mx.int32) + z_q = self.decode_code(indices) + return z_q, indices, z_e.astype(mx.float32) + + +class MossAudioTokenizerLFQ(nn.Module): + """LFQ codebook used by ResidualLFQ.""" + + def __init__( + self, + input_dim: int, + codebook_size: int, + codebook_dim: int, + ): + super().__init__() + self.input_dim = input_dim + self.codebook_size = codebook_size + self.codebook_dim = codebook_dim + + if input_dim != codebook_dim: + self.in_proj = Conv1d(input_dim, codebook_dim, 1, bias=True) + self.out_proj = Conv1d(codebook_dim, input_dim, 1, bias=True) + else: + self.in_proj = None + self.out_proj = None + + self.codebook = nn.Embedding(codebook_size, codebook_dim) + + def decode_code_wo_out_proj(self, indices: mx.array) -> mx.array: + return self.codebook(indices).swapaxes(1, 2).astype(mx.float32) + + def decode_code(self, indices: mx.array) -> mx.array: + z_q = self.decode_code_wo_out_proj(indices).astype(mx.float32) + if self.out_proj is not None: + z_q = self.out_proj(z_q) + return z_q.astype(mx.float32) + + def _decode_latents(self, latents: mx.array) -> tuple[mx.array, mx.array]: + encodings = ( + latents.swapaxes(1, 2).reshape(-1, latents.shape[1]).astype(mx.float32) + ) + codebook = self.codebook.weight.astype(mx.float32) + + encodings = _l2_normalize(encodings, axis=-1) + codebook = _l2_normalize(codebook, axis=-1) + + distances = ( + mx.sum(encodings**2, axis=1, keepdims=True) + - 2.0 * (encodings @ codebook.transpose()) + + mx.sum(codebook**2, axis=1)[None, :] + ) + indices = ( + mx.argmax(-distances, axis=1).reshape(latents.shape[0], -1).astype(mx.int32) + ) + z_q = self.decode_code_wo_out_proj(indices) + return z_q.astype(mx.float32), indices + + def __call__(self, z: mx.array) -> tuple[mx.array, mx.array, mx.array]: + z = z.astype(mx.float32) + z_e = self.in_proj(z) if self.in_proj is not None else z + z_q, indices = self._decode_latents(z_e.astype(mx.float32)) + if self.out_proj is not None: + z_q = self.out_proj(z_q) + return z_q.astype(mx.float32), indices, z_e.astype(mx.float32) + + +class MossAudioTokenizerResidualVQ(nn.Module): + """Residual VQ stack.""" + + def __init__( + self, + input_dim: int, + rvq_dim: int, + output_dim: int, + num_quantizers: int, + codebook_size: int, + codebook_dim: int, + ): + super().__init__() + self.input_dim = input_dim + self.rvq_dim = rvq_dim + self.output_dim = output_dim + self.num_quantizers = num_quantizers + + self.input_proj = ( + Conv1d(input_dim, rvq_dim, 1, bias=True) if input_dim != rvq_dim else None + ) + self.output_proj = ( + Conv1d(rvq_dim, output_dim, 1, bias=True) if rvq_dim != output_dim else None + ) + self.quantizers = [ + MossAudioTokenizerVectorQuantize( + input_dim=rvq_dim, + codebook_size=codebook_size, + codebook_dim=codebook_dim, + ) + for _ in range(num_quantizers) + ] + + def __call__( + self, + z: mx.array, + input_length: mx.array, + n_quantizers: Optional[int] = None, + ) -> tuple[mx.array, mx.array, mx.array]: + z = self.input_proj(z) if self.input_proj is not None else z + z = z.astype(mx.float32) + batch_size, _, max_time = z.shape + mask = _mask_from_lengths(input_length, max_time, z.dtype) + + quantized_out = mx.zeros_like(z).astype(mx.float32) + residual = z.astype(mx.float32) + all_indices = [] + n_quantizers = int(n_quantizers or self.num_quantizers) + + for quantizer in self.quantizers[:n_quantizers]: + z_q_i, indices_i, _ = quantizer(residual * mask) + quantized_out = quantized_out + z_q_i * mask + residual = residual - z_q_i * mask + all_indices.append(indices_i.astype(mx.int32)) + + if all_indices: + audio_codes = mx.stack(all_indices, axis=0) + else: + audio_codes = mx.zeros((0, batch_size, max_time), dtype=mx.int32) + + if self.output_proj is not None: + quantized_out = self.output_proj(quantized_out) + return ( + quantized_out.astype(mx.float32), + audio_codes, + input_length.astype(mx.int32), + ) + + def decode_codes(self, codes: mx.array) -> mx.array: + nq, batch_size, time_steps = codes.shape + embeddings = mx.zeros((batch_size, self.rvq_dim, time_steps), dtype=mx.float32) + for i, quantizer in enumerate(self.quantizers[:nq]): + embeddings = embeddings + quantizer.decode_code(codes[i]).astype(mx.float32) + if self.output_proj is not None: + embeddings = self.output_proj(embeddings) + return embeddings.astype(mx.float32) + + +class MossAudioTokenizerResidualLFQ(nn.Module): + """Residual LFQ stack.""" + + def __init__( + self, + input_dim: int, + rvq_dim: int, + output_dim: int, + num_quantizers: int, + codebook_size: int, + codebook_dim: int, + ): + super().__init__() + self.input_dim = input_dim + self.rvq_dim = rvq_dim + self.output_dim = output_dim + self.num_quantizers = num_quantizers + + self.input_proj = ( + Conv1d(input_dim, rvq_dim, 1, bias=True) if input_dim != rvq_dim else None + ) + self.output_proj = ( + Conv1d(rvq_dim, output_dim, 1, bias=True) if rvq_dim != output_dim else None + ) + self.quantizers = [ + MossAudioTokenizerLFQ( + input_dim=rvq_dim, + codebook_size=codebook_size, + codebook_dim=codebook_dim, + ) + for _ in range(num_quantizers) + ] + + def __call__( + self, + z: mx.array, + input_length: mx.array, + n_quantizers: Optional[int] = None, + ) -> tuple[mx.array, mx.array, mx.array]: + z = self.input_proj(z) if self.input_proj is not None else z + z = z.astype(mx.float32) + batch_size, _, max_time = z.shape + mask = _mask_from_lengths(input_length, max_time, z.dtype) + + quantized_out = mx.zeros_like(z).astype(mx.float32) + residual = z.astype(mx.float32) + all_indices = [] + n_quantizers = int(n_quantizers or self.num_quantizers) + + for quantizer in self.quantizers[:n_quantizers]: + z_q_i, indices_i, _ = quantizer(residual * mask) + quantized_out = quantized_out + z_q_i * mask + residual = residual - z_q_i * mask + all_indices.append(indices_i.astype(mx.int32)) + + if all_indices: + audio_codes = mx.stack(all_indices, axis=0) + else: + audio_codes = mx.zeros((0, batch_size, max_time), dtype=mx.int32) + + if self.output_proj is not None: + quantized_out = self.output_proj(quantized_out) + return ( + quantized_out.astype(mx.float32), + audio_codes, + input_length.astype(mx.int32), + ) + + def decode_codes(self, codes: mx.array) -> mx.array: + nq, batch_size, time_steps = codes.shape + embeddings = mx.zeros((batch_size, self.rvq_dim, time_steps), dtype=mx.float32) + for i, quantizer in enumerate(self.quantizers[:nq]): + embeddings = embeddings + quantizer.decode_code(codes[i]).astype(mx.float32) + if self.output_proj is not None: + embeddings = self.output_proj(embeddings) + return embeddings.astype(mx.float32) + + +def build_moss_audio_tokenizer_quantizer( + config: MossAudioTokenizerQuantizerConfig, +) -> MossAudioTokenizerResidualLFQ | MossAudioTokenizerResidualVQ: + quantizer_type = config.quantizer_type + if quantizer_type in {"rlfq", "random_prefix_rlfq"}: + return MossAudioTokenizerResidualLFQ( + input_dim=config.input_dim, + rvq_dim=config.rvq_dim, + output_dim=config.output_dim, + num_quantizers=config.num_quantizers, + codebook_size=config.codebook_size, + codebook_dim=config.codebook_dim, + ) + if quantizer_type in {"rvq", "spec_rvq"}: + return MossAudioTokenizerResidualVQ( + input_dim=config.input_dim, + rvq_dim=config.rvq_dim, + output_dim=config.output_dim, + num_quantizers=config.num_quantizers, + codebook_size=config.codebook_size, + codebook_dim=config.codebook_dim, + ) + raise ValueError(f"Unsupported quantizer_type: {quantizer_type}") diff --git a/mlx_audio/codec/tests/test_moss_audio_tokenizer.py b/mlx_audio/codec/tests/test_moss_audio_tokenizer.py new file mode 100644 index 000000000..efe75f83b --- /dev/null +++ b/mlx_audio/codec/tests/test_moss_audio_tokenizer.py @@ -0,0 +1,602 @@ +import json +import tempfile +import unittest +from dataclasses import asdict +from pathlib import Path +from unittest.mock import patch + +import mlx.core as mx +import numpy as np +from mlx.utils import tree_flatten + +from mlx_audio.codec.models.moss_audio_tokenizer import ( + MossAudioTokenizer, + MossAudioTokenizerConfig, + MossAudioTokenizerDecoderOutput, + MossAudioTokenizerModuleConfig, + MossAudioTokenizerQuantizerConfig, +) + + +def _tiny_moss_config() -> MossAudioTokenizerConfig: + return MossAudioTokenizerConfig( + model_type="moss_audio_tokenizer", + sampling_rate=24, + downsample_rate=4, + causal_transformer_context_duration=1.0, + encoder_modules=[ + MossAudioTokenizerModuleConfig( + module_type="PatchedPretransform", patch_size=2 + ), + MossAudioTokenizerModuleConfig( + module_type="Transformer", + input_dimension=2, + output_dimension=2, + d_model=4, + num_heads=1, + num_layers=1, + dim_feedforward=8, + causal=True, + norm="layer_norm", + positional_embedding="rope", + max_period=10000, + gating="none", + layer_scale=0.01, + conv_layout=True, + ), + MossAudioTokenizerModuleConfig( + module_type="PatchedPretransform", patch_size=2 + ), + MossAudioTokenizerModuleConfig( + module_type="Transformer", + input_dimension=4, + output_dimension=4, + d_model=4, + num_heads=1, + num_layers=1, + dim_feedforward=8, + causal=True, + norm="layer_norm", + positional_embedding="rope", + max_period=10000, + gating="none", + layer_scale=0.01, + conv_layout=True, + ), + ], + decoder_modules=[ + MossAudioTokenizerModuleConfig( + module_type="Transformer", + input_dimension=4, + output_dimension=4, + d_model=4, + num_heads=1, + num_layers=1, + dim_feedforward=8, + causal=True, + norm="layer_norm", + positional_embedding="rope", + max_period=10000, + gating="none", + layer_scale=0.01, + conv_layout=True, + ), + MossAudioTokenizerModuleConfig( + module_type="PatchedPretransform", patch_size=2 + ), + MossAudioTokenizerModuleConfig( + module_type="Transformer", + input_dimension=2, + output_dimension=2, + d_model=4, + num_heads=1, + num_layers=1, + dim_feedforward=8, + causal=True, + norm="layer_norm", + positional_embedding="rope", + max_period=10000, + gating="none", + layer_scale=0.01, + conv_layout=True, + ), + MossAudioTokenizerModuleConfig( + module_type="PatchedPretransform", patch_size=2 + ), + ], + quantizer=MossAudioTokenizerQuantizerConfig( + input_dim=4, + rvq_dim=2, + output_dim=4, + num_quantizers=2, + codebook_size=8, + codebook_dim=2, + quantizer_type="rlfq", + ), + ) + + +class TestMossAudioTokenizerModel(unittest.TestCase): + def test_encode_decode_shape_contract(self): + model = MossAudioTokenizer(_tiny_moss_config()) + audio = mx.random.normal((1, 1, 37)) + + enc = model.encode(audio, return_dict=True) + self.assertIsNotNone(enc.audio_codes) + self.assertIsNotNone(enc.audio_codes_lengths) + + assert enc.audio_codes is not None + assert enc.audio_codes_lengths is not None + self.assertEqual(enc.audio_codes.shape[0], 2) + self.assertEqual(enc.audio_codes.shape[1], 1) + self.assertEqual(int(enc.audio_codes_lengths[0]), 37 // 4) + + dec = model.decode(enc.audio_codes, return_dict=True) + self.assertIsNotNone(dec.audio) + self.assertIsNotNone(dec.audio_lengths) + + assert dec.audio is not None + assert dec.audio_lengths is not None + self.assertEqual(dec.audio.shape[0], 1) + self.assertEqual(dec.audio.shape[1], 1) + self.assertEqual(int(dec.audio_lengths[0]), int(enc.audio_codes.shape[-1]) * 4) + + def test_batch_encode_and_batch_decode(self): + model = MossAudioTokenizer(_tiny_moss_config()) + wav_a = mx.random.normal((13,)) + wav_b = mx.random.normal((27,)) + + enc = model.batch_encode([wav_a, wav_b], num_quantizers=2) + self.assertIsNotNone(enc.audio_codes) + self.assertIsNotNone(enc.audio_codes_lengths) + + assert enc.audio_codes is not None + assert enc.audio_codes_lengths is not None + self.assertEqual(enc.audio_codes.shape[:2], (2, 2)) + self.assertEqual(int(enc.audio_codes_lengths[0]), 13 // 4) + self.assertEqual(int(enc.audio_codes_lengths[1]), 27 // 4) + + codes_list = [ + enc.audio_codes[:, i, : int(enc.audio_codes_lengths[i])] + for i in range(enc.audio_codes.shape[1]) + ] + dec = model.batch_decode(codes_list, num_quantizers=2) + self.assertIsNotNone(dec.audio) + self.assertIsNotNone(dec.audio_lengths) + + assert dec.audio is not None + assert dec.audio_lengths is not None + self.assertEqual(dec.audio.shape[0], 2) + self.assertEqual(dec.audio.shape[1], 1) + self.assertEqual(int(dec.audio_lengths[0]), (13 // 4) * 4) + self.assertEqual(int(dec.audio_lengths[1]), (27 // 4) * 4) + + def test_decode_accepts_b_t_nq_layout(self): + model = MossAudioTokenizer(_tiny_moss_config()) + audio = mx.random.normal((1, 1, 40)) + enc = model.encode(audio, return_dict=True) + assert enc.audio_codes is not None + assert enc.audio_codes_lengths is not None + + codes_b_t_nq = enc.audio_codes.transpose(1, 2, 0) + dec = model.decode(codes_b_t_nq, return_dict=True) + assert dec.audio is not None + assert dec.audio_lengths is not None + self.assertEqual(int(dec.audio_lengths[0]), int(enc.audio_codes_lengths[0]) * 4) + + def test_decode_accepts_quantizer_prefix_for_full_and_prefix_inputs(self): + model = MossAudioTokenizer(_tiny_moss_config()) + audio = mx.random.normal((1, 1, 40)) + enc = model.encode(audio, return_dict=True) + assert enc.audio_codes is not None + assert enc.audio_codes_lengths is not None + + dec_from_full = model.decode( + enc.audio_codes, + num_quantizers=1, + return_dict=True, + ) + dec_from_prefix = model.decode( + enc.audio_codes[:1], + num_quantizers=1, + return_dict=True, + ) + assert dec_from_full.audio is not None + assert dec_from_full.audio_lengths is not None + assert dec_from_prefix.audio is not None + assert dec_from_prefix.audio_lengths is not None + self.assertEqual( + int(dec_from_full.audio_lengths[0]), + int(dec_from_prefix.audio_lengths[0]), + ) + + def test_batch_decode_accepts_quantizer_prefix(self): + model = MossAudioTokenizer(_tiny_moss_config()) + wav_a = mx.random.normal((17,)) + wav_b = mx.random.normal((29,)) + + enc = model.batch_encode([wav_a, wav_b], num_quantizers=2) + assert enc.audio_codes is not None + assert enc.audio_codes_lengths is not None + prefix_codes_list = [ + enc.audio_codes[:1, i, : int(enc.audio_codes_lengths[i])] + for i in range(enc.audio_codes.shape[1]) + ] + dec = model.batch_decode(prefix_codes_list, num_quantizers=1) + assert dec.audio is not None + assert dec.audio_lengths is not None + self.assertEqual(int(dec.audio_lengths[0]), (17 // 4) * 4) + self.assertEqual(int(dec.audio_lengths[1]), (29 // 4) * 4) + + def test_streaming_decode_matches_non_streaming_length(self): + model = MossAudioTokenizer(_tiny_moss_config()) + audio = mx.random.normal((1, 1, 64)) + enc = model.encode(audio, return_dict=True) + assert enc.audio_codes is not None + + full = model.decode(enc.audio_codes, return_dict=True) + assert full.audio is not None + assert full.audio_lengths is not None + + chunks = list(model.streaming_decode(enc.audio_codes, chunk_tokens=2)) + stream_concat = mx.concatenate(chunks, axis=-1) + self.assertEqual(stream_concat.shape[0], 1) + self.assertEqual(stream_concat.shape[1], 1) + self.assertEqual(stream_concat.shape[-1], int(full.audio_lengths[0])) + + def test_streaming_decode_accepts_quantizer_prefix(self): + model = MossAudioTokenizer(_tiny_moss_config()) + audio = mx.random.normal((1, 1, 64)) + enc = model.encode(audio, return_dict=True) + assert enc.audio_codes is not None + + full = model.decode(enc.audio_codes, num_quantizers=1, return_dict=True) + assert full.audio is not None + assert full.audio_lengths is not None + + chunks = list( + model.streaming_decode( + enc.audio_codes[:1], chunk_tokens=2, num_quantizers=1 + ) + ) + stream_concat = mx.concatenate(chunks, axis=-1) + self.assertEqual(stream_concat.shape[-1], int(full.audio_lengths[0])) + + def test_decode_accepts_encode_output_when_time_equals_quantizers(self): + model = MossAudioTokenizer(_tiny_moss_config()) + # downsample_rate=4 => 8 samples encodes to 2 frames, matching num_quantizers=2. + audio = mx.random.normal((1, 1, 8)) + enc = model.encode(audio, return_dict=True) + assert enc.audio_codes is not None + assert enc.audio_codes_lengths is not None + self.assertEqual(tuple(enc.audio_codes.shape), (2, 1, 2)) + + dec = model.decode(enc.audio_codes, return_dict=True) + assert dec.audio is not None + assert dec.audio_lengths is not None + self.assertEqual(dec.audio.shape[0], 1) + self.assertEqual(int(dec.audio_lengths[0]), int(enc.audio_codes_lengths[0]) * 4) + + def test_decode_prefers_nq_first_when_3d_shape_matches_both_orientations(self): + model = MossAudioTokenizer(_tiny_moss_config()) + tie_shape_codes = mx.zeros((2, 5, 2), dtype=mx.int32) + + dec = model.decode(tie_shape_codes, return_dict=True) + assert dec.audio is not None + assert dec.audio_lengths is not None + self.assertEqual(dec.audio.shape[0], 5) + self.assertTrue(np.all(np.array(dec.audio_lengths) == 8)) + + def test_decode_preserves_canonical_3d_tie_for_explicit_configured_num_quantizers( + self, + ): + model = MossAudioTokenizer(_tiny_moss_config()) + tie_shape_codes = mx.array( + [ + [[1, 2], [3, 4], [5, 6]], + [[7, 8], [9, 10], [11, 12]], + ], + dtype=mx.int32, + ) + captured: list[np.ndarray] = [] + + def fake_decode_frame(audio_codes, audio_codes_lengths=None, caches=None): + del audio_codes_lengths, caches + captured.append(np.array(audio_codes)) + batch = int(audio_codes.shape[1]) + time_steps = int(audio_codes.shape[2]) + sample_count = time_steps * model.downsample_rate + return MossAudioTokenizerDecoderOutput( + audio=mx.zeros((batch, 1, sample_count), dtype=mx.float32), + audio_lengths=mx.full((batch,), sample_count, dtype=mx.int32), + ) + + with patch.object(model, "_decode_frame", side_effect=fake_decode_frame): + dec = model.decode(tie_shape_codes, num_quantizers=2, return_dict=True) + + assert dec.audio is not None + self.assertEqual(len(captured), 1) + np.testing.assert_array_equal(captured[0], np.array(tie_shape_codes)) + + def test_decode_prefers_requested_quantizer_match_for_2d_nq_last_prefix_tie(self): + model = MossAudioTokenizer(_tiny_moss_config()) + tie_shape_codes = mx.zeros((2, 1), dtype=mx.int32) + + dec = model.decode(tie_shape_codes, num_quantizers=1, return_dict=True) + assert dec.audio is not None + assert dec.audio_lengths is not None + self.assertEqual(dec.audio.shape[0], 1) + self.assertEqual(int(dec.audio_lengths[0]), 8) + + def test_decode_prefers_requested_quantizer_match_for_3d_nq_last_prefix_tie(self): + model = MossAudioTokenizer(_tiny_moss_config()) + tie_shape_codes = mx.zeros((2, 1, 1), dtype=mx.int32) + + dec = model.decode(tie_shape_codes, num_quantizers=1, return_dict=True) + assert dec.audio is not None + assert dec.audio_lengths is not None + self.assertEqual(dec.audio.shape[0], 2) + self.assertTrue(np.all(np.array(dec.audio_lengths) == 4)) + + def test_decode_preserves_canonical_orientation_for_true_prefix_3d_tie(self): + model = MossAudioTokenizer(_tiny_moss_config()) + tie_shape_codes = mx.array( + [ + [[1], [2], [3]], + ], + dtype=mx.int32, + ) + captured: list[np.ndarray] = [] + + def fake_decode_frame(audio_codes, audio_codes_lengths=None, caches=None): + del audio_codes_lengths, caches + captured.append(np.array(audio_codes)) + batch = int(audio_codes.shape[1]) + time_steps = int(audio_codes.shape[2]) + sample_count = time_steps * model.downsample_rate + return MossAudioTokenizerDecoderOutput( + audio=mx.zeros((batch, 1, sample_count), dtype=mx.float32), + audio_lengths=mx.full((batch,), sample_count, dtype=mx.int32), + ) + + with patch.object(model, "_decode_frame", side_effect=fake_decode_frame): + dec = model.decode(tie_shape_codes, num_quantizers=1, return_dict=True) + + assert dec.audio is not None + self.assertEqual(len(captured), 1) + np.testing.assert_array_equal(captured[0], np.array(tie_shape_codes)) + + def test_decode_preserves_nq_first_for_explicit_square_tie(self): + model = MossAudioTokenizer(_tiny_moss_config()) + tie_shape_codes = mx.array([[1, 2], [3, 4]], dtype=mx.int32) + captured: list[np.ndarray] = [] + + def fake_decode_frame(audio_codes, audio_codes_lengths=None, caches=None): + del audio_codes_lengths, caches + captured.append(np.array(audio_codes)) + batch = int(audio_codes.shape[1]) + time_steps = int(audio_codes.shape[2]) + sample_count = time_steps * model.downsample_rate + return MossAudioTokenizerDecoderOutput( + audio=mx.zeros((batch, 1, sample_count), dtype=mx.float32), + audio_lengths=mx.full((batch,), sample_count, dtype=mx.int32), + ) + + with patch.object(model, "_decode_frame", side_effect=fake_decode_frame): + dec = model.decode(tie_shape_codes, num_quantizers=2, return_dict=True) + + assert dec.audio is not None + self.assertEqual(len(captured), 1) + expected = np.array([[[1, 2]], [[3, 4]]], dtype=np.int32) + np.testing.assert_array_equal(captured[0], expected) + + def test_batch_decode_prefers_requested_quantizer_match_for_2d_nq_last_prefix_tie( + self, + ): + model = MossAudioTokenizer(_tiny_moss_config()) + tie_shape_codes = mx.zeros((2, 1), dtype=mx.int32) + + dec = model.batch_decode([tie_shape_codes], num_quantizers=1) + assert dec.audio is not None + assert dec.audio_lengths is not None + self.assertEqual(dec.audio.shape[0], 1) + self.assertEqual(int(dec.audio_lengths[0]), 8) + + def test_batch_decode_rejects_canonical_batched_true_prefix_3d_tie( + self, + ): + model = MossAudioTokenizer(_tiny_moss_config()) + tie_shape_codes = mx.zeros((1, 3, 1), dtype=mx.int32) + + with self.assertRaisesRegex( + ValueError, + "batch_decode\\(\\) expects each codes_list entry to resolve to batch_size=1", + ): + _ = model.batch_decode([tie_shape_codes], num_quantizers=1) + + def test_batch_decode_preserves_nq_first_for_explicit_square_tie(self): + model = MossAudioTokenizer(_tiny_moss_config()) + tie_shape_codes = mx.array([[1, 2], [3, 4]], dtype=mx.int32) + captured: list[np.ndarray] = [] + + def fake_decode_frame(audio_codes, audio_codes_lengths=None, caches=None): + del audio_codes_lengths, caches + captured.append(np.array(audio_codes)) + batch = int(audio_codes.shape[1]) + time_steps = int(audio_codes.shape[2]) + sample_count = time_steps * model.downsample_rate + return MossAudioTokenizerDecoderOutput( + audio=mx.zeros((batch, 1, sample_count), dtype=mx.float32), + audio_lengths=mx.full((batch,), sample_count, dtype=mx.int32), + ) + + with patch.object(model, "_decode_frame", side_effect=fake_decode_frame): + dec = model.batch_decode([tie_shape_codes], num_quantizers=2) + + assert dec.audio is not None + self.assertEqual(len(captured), 1) + expected = np.array([[[1, 2]], [[3, 4]]], dtype=np.int32) + np.testing.assert_array_equal(captured[0], expected) + + def test_streaming_decode_prefers_requested_quantizer_match_for_2d_nq_last_prefix_tie( + self, + ): + model = MossAudioTokenizer(_tiny_moss_config()) + tie_shape_codes = mx.zeros((2, 1), dtype=mx.int32) + + chunks = list( + model.streaming_decode( + tie_shape_codes, + chunk_tokens=1, + num_quantizers=1, + ) + ) + stream_concat = mx.concatenate(chunks, axis=-1) + self.assertEqual(stream_concat.shape[0], 1) + self.assertEqual(stream_concat.shape[1], 1) + self.assertEqual(stream_concat.shape[-1], 8) + + def test_streaming_decode_preserves_nq_first_for_explicit_square_tie(self): + model = MossAudioTokenizer(_tiny_moss_config()) + tie_shape_codes = mx.array( + [ + [[1, 2]], + [[3, 4]], + ], + dtype=mx.int32, + ) + captured: list[np.ndarray] = [] + + def fake_decode_frame(audio_codes, audio_codes_lengths=None, caches=None): + del audio_codes_lengths, caches + captured.append(np.array(audio_codes)) + batch = int(audio_codes.shape[1]) + time_steps = int(audio_codes.shape[2]) + sample_count = time_steps * model.downsample_rate + return MossAudioTokenizerDecoderOutput( + audio=mx.zeros((batch, 1, sample_count), dtype=mx.float32), + audio_lengths=mx.full((batch,), sample_count, dtype=mx.int32), + ) + + with patch.object(model, "_decode_frame", side_effect=fake_decode_frame): + _ = list( + model.streaming_decode( + tie_shape_codes, + chunk_tokens=1, + num_quantizers=2, + ) + ) + + self.assertGreaterEqual(len(captured), 1) + reconstructed = np.concatenate(captured, axis=2) + expected = np.array([[[1, 2]], [[3, 4]]], dtype=np.int32) + np.testing.assert_array_equal(reconstructed, expected) + + def test_streaming_decode_rejects_canonical_batched_true_prefix_3d_tie( + self, + ): + model = MossAudioTokenizer(_tiny_moss_config()) + tie_shape_codes = mx.zeros((1, 3, 1), dtype=mx.int32) + + with self.assertRaisesRegex( + ValueError, + "streaming_decode currently only supports batch_size=1", + ): + _ = list( + model.streaming_decode( + tie_shape_codes, + chunk_tokens=1, + num_quantizers=1, + ) + ) + + def test_sanitize_reconstructs_weight_norm(self): + model = MossAudioTokenizer(_tiny_moss_config()) + expected_shapes = { + name: tuple(value.shape) for name, value in tree_flatten(model.parameters()) + } + linear_key = next( + key + for key in expected_shapes + if key.endswith("encoder.1.transformer.layers.0.linear1.weight") + ) + linear_shape = expected_shapes[linear_key] + + expected_input_proj_shape = expected_shapes["quantizer.input_proj.weight"] + pytorch_v_shape = ( + expected_input_proj_shape[0], + expected_input_proj_shape[2], + expected_input_proj_shape[1], + ) + g = mx.ones((pytorch_v_shape[0], 1, 1), dtype=mx.float32) + v = mx.arange( + 1, + 1 + int(np.prod(np.array(pytorch_v_shape))), + dtype=mx.float32, + ).reshape(pytorch_v_shape) + weights = { + "quantizer.input_proj.parametrizations.weight.original0": g, + "quantizer.input_proj.parametrizations.weight.original1": v, + linear_key: mx.ones(linear_shape, dtype=mx.float32), + } + sanitized = model.sanitize(weights) + + self.assertIn("quantizer.input_proj.weight", sanitized) + self.assertEqual( + sanitized["quantizer.input_proj.weight"].shape, + expected_shapes["quantizer.input_proj.weight"], + ) + self.assertNotIn( + "quantizer.input_proj.parametrizations.weight.original0", sanitized + ) + self.assertNotIn( + "quantizer.input_proj.parametrizations.weight.original1", sanitized + ) + self.assertTrue( + np.allclose(np.array(sanitized[linear_key]), np.array(weights[linear_key])) + ) + + def test_from_pretrained_loads_local_directory(self): + model = MossAudioTokenizer(_tiny_moss_config()) + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + config_path = root / "config.json" + config_payload = asdict(model.config) + config_payload["model_type"] = "speech_tokenizer" + config_path.write_text(json.dumps(config_payload), encoding="utf-8") + + weight_path = root / "model.safetensors" + mx.save_safetensors( + weight_path.as_posix(), + dict(tree_flatten(model.parameters())), + ) + + loaded = MossAudioTokenizer.from_pretrained(root) + audio = mx.random.normal((1, 1, 20)) + enc = loaded.encode(audio, return_dict=True) + self.assertIsNotNone(enc.audio_codes) + self.assertIsNotNone(enc.audio_codes_lengths) + + +class TestMossAudioTokenizerQuantPredicate(unittest.TestCase): + def test_model_quant_predicate_skips_embeddings(self): + model = MossAudioTokenizer(_tiny_moss_config()) + codebook_module = model.quantizer.quantizers[0].codebook + linear_module = model.encoder[1].transformer.layers[0].linear1 + + self.assertFalse( + model.model_quant_predicate( + "quantizer.quantizers.0.codebook", + codebook_module, + ) + ) + self.assertTrue( + model.model_quant_predicate( + "encoder.1.transformer.layers.0.linear1", + linear_module, + ) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/mlx_audio/codec/tests/test_moss_audio_tokenizer_config_contracts.py b/mlx_audio/codec/tests/test_moss_audio_tokenizer_config_contracts.py new file mode 100644 index 000000000..d1cbcc48b --- /dev/null +++ b/mlx_audio/codec/tests/test_moss_audio_tokenizer_config_contracts.py @@ -0,0 +1,45 @@ +import unittest +from pathlib import Path + +from mlx_audio.codec.models.moss_audio_tokenizer.config import ( + CANONICAL_MODEL_TYPE, + load_moss_audio_tokenizer_config, +) + + +def _repo_root() -> Path: + # .../mlx_audio/codec/tests -> project root + return Path(__file__).resolve().parents[3] + + +def _fixture_path() -> Path: + return ( + _repo_root() + / "tests" + / "fixtures" + / "moss" + / "MOSS-Audio-Tokenizer" + / "config.json" + ) + + +class TestMossAudioTokenizerPhase0Config(unittest.TestCase): + def test_load_upstream_config_contract(self): + config = load_moss_audio_tokenizer_config(_fixture_path()) + self.assertEqual(config.model_type, CANONICAL_MODEL_TYPE) + self.assertEqual(config.sampling_rate, 24000) + self.assertEqual(config.downsample_rate, 1920) + self.assertEqual(config.frame_rate, 12.5) + self.assertEqual(config.quantizer.num_quantizers, 32) + self.assertEqual(config.quantizer.codebook_size, 1024) + self.assertEqual(config.quantizer.quantizer_type, "rlfq") + + def test_patch_products_match_downsample_rate(self): + config = load_moss_audio_tokenizer_config(_fixture_path()) + self.assertEqual(config.encoder_patch_product, 1920) + self.assertEqual(config.decoder_patch_product, 1920) + self.assertTrue(config.patch_alignment_is_valid()) + + +if __name__ == "__main__": + unittest.main() diff --git a/mlx_audio/convert.py b/mlx_audio/convert.py index 256a25de5..6aa389136 100644 --- a/mlx_audio/convert.py +++ b/mlx_audio/convert.py @@ -152,9 +152,14 @@ def _discover_detection_hints(domain: str) -> dict: module_path = f"mlx_audio.{domain}.models.{model_type}" try: module = importlib.import_module(module_path) + has_model_entrypoint = hasattr(module, "Model") + has_model_config = hasattr(module, "ModelConfig") # Check for explicit detection hints if hasattr(module, "DETECTION_HINTS"): + if not has_model_entrypoint: + # Bootstrap-only modules should not participate in autodetection. + continue model_hints = module.DETECTION_HINTS if "config_keys" in model_hints: hints["config_keys"][model_type] = set(model_hints["config_keys"]) @@ -168,15 +173,16 @@ def _discover_detection_hints(domain: str) -> dict: ) else: # Infer from ModelConfig if available - if hasattr(module, "ModelConfig"): + if has_model_config: config_keys = _get_config_keys(module.ModelConfig) hints["config_keys"][model_type] = config_keys # Use model_type as default path pattern - hints["path_patterns"][model_type] = { - model_type, - model_type.replace("_", ""), - } + if has_model_entrypoint: + hints["path_patterns"][model_type] = { + model_type, + model_type.replace("_", ""), + } except ImportError: continue @@ -565,6 +571,11 @@ def convert( # Get model class and instantiate model_class = get_model_class(model_type, domain) + if not hasattr(model_class, "Model"): + raise ValueError( + f"Model module '{domain.value}.models.{model_type}' does not expose " + "a Model entry point yet." + ) model_config = ( model_class.ModelConfig.from_dict(config) diff --git a/mlx_audio/tts/utils.py b/mlx_audio/tts/utils.py index 2933b2cbd..d746f941b 100644 --- a/mlx_audio/tts/utils.py +++ b/mlx_audio/tts/utils.py @@ -27,6 +27,7 @@ "vibevoice_streaming": "vibevoice", "chatterbox_turbo": "chatterbox_turbo", "soprano": "soprano", + "moss_tts_delay": "moss_tts", } MAX_FILE_SIZE_GB = 5 MODEL_CONVERSION_DTYPES = ["float16", "bfloat16", "float32"] diff --git a/mlx_audio/utils.py b/mlx_audio/utils.py index c23036995..e6af1f608 100644 --- a/mlx_audio/utils.py +++ b/mlx_audio/utils.py @@ -272,11 +272,14 @@ def get_model_class( Raises: ValueError: If the model type is not supported. """ - # Stage 1: Check if the model type is in the remapping + # Stage 1: Apply direct model_type remapping when available. model_type_mapped = model_remapping.get(model_type, None) + stage1_resolved = model_type_mapped is not None + if stage1_resolved: + model_type = model_type_mapped - # Stage 2: Check for partial matches in segments of the model name - # Only do this if the initial mapping didn't find a match + # Stage 2: Check for partial matches in segments of the model name. + # This can further specialize the final type. models_dir = Path(__file__).parent / category / "models" available_models = [] if models_dir.exists() and models_dir.is_dir(): @@ -284,15 +287,13 @@ def get_model_class( if item.is_dir() and not item.name.startswith("__"): available_models.append(item.name) - if model_name is not None and model_type_mapped != model_type: + if not stage1_resolved and model_name is not None: for part in model_name: if part in available_models: model_type = part if part in model_remapping: model_type = model_remapping[part] break - elif model_type_mapped is not None: - model_type = model_type_mapped try: module_path = f"mlx_audio.{category}.models.{model_type}" diff --git a/scripts/convert_moss_models.py b/scripts/convert_moss_models.py new file mode 100644 index 000000000..769b8cc4b --- /dev/null +++ b/scripts/convert_moss_models.py @@ -0,0 +1,435 @@ +#!/usr/bin/env python +"""Batch converter for OpenMOSS models. + +This script can convert any single MOSS model or the full MOSS catalog. + +Supported catalog entries: +- `moss_tts` +- `moss_tts_local` +- `moss_ttsd` +- `moss_voice_generator` +- `moss_soundeffect` +- `moss_tts_realtime` +- `moss_audio_tokenizer` + +Examples: + # Convert one model + uv run python scripts/convert_moss_models.py --models moss_tts_local + + # Convert two models with bf16 output + uv run python scripts/convert_moss_models.py \ + --models moss_tts,moss_voice_generator --dtype bfloat16 + + # Convert all MOSS models + uv run python scripts/convert_moss_models.py --all + + # Convert all, quantizing TTS models to 4-bit (codec remains non-quantized) + uv run python scripts/convert_moss_models.py --all --quantize --q-bits 4 +""" + +from __future__ import annotations + +import argparse +import shutil +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, List, Sequence + +import mlx.core as mx +from mlx.utils import tree_flatten + +from mlx_audio.codec.models.moss_audio_tokenizer import MossAudioTokenizer +from mlx_audio.convert import MODEL_CONVERSION_DTYPES, QUANT_RECIPES +from mlx_audio.convert import convert as convert_generic_model +from mlx_audio.convert import get_model_path + + +@dataclass(frozen=True) +class MossModelSpec: + key: str + hf_id: str + kind: str # "tts" or "codec" + aliases: tuple[str, ...] = () + + +MOSS_CATALOG: Dict[str, MossModelSpec] = { + "moss_tts": MossModelSpec( + key="moss_tts", + hf_id="OpenMOSS-Team/MOSS-TTS", + kind="tts", + aliases=("delay", "moss_tts_delay"), + ), + "moss_tts_local": MossModelSpec( + key="moss_tts_local", + hf_id="OpenMOSS-Team/MOSS-TTS-Local-Transformer", + kind="tts", + aliases=("moss_tts_local_transformer", "local"), + ), + "moss_ttsd": MossModelSpec( + key="moss_ttsd", + hf_id="OpenMOSS-Team/MOSS-TTSD-v1.0", + kind="tts", + aliases=("ttsd",), + ), + "moss_voice_generator": MossModelSpec( + key="moss_voice_generator", + hf_id="OpenMOSS-Team/MOSS-Voice-Generator", + kind="tts", + aliases=( + "voice_generator", + "voicegenerator", + "moss_voicegenerator", + "moss-voicegenerator", + "moss-voice-generator", + ), + ), + "moss_soundeffect": MossModelSpec( + key="moss_soundeffect", + hf_id="OpenMOSS-Team/MOSS-SoundEffect", + kind="tts", + aliases=("soundeffect", "moss_sound_effect", "sound_effect"), + ), + "moss_tts_realtime": MossModelSpec( + key="moss_tts_realtime", + hf_id="OpenMOSS-Team/MOSS-TTS-Realtime", + kind="tts", + aliases=("realtime", "mossrealtime", "moss-tts-realtime"), + ), + "moss_audio_tokenizer": MossModelSpec( + key="moss_audio_tokenizer", + hf_id="OpenMOSS-Team/MOSS-Audio-Tokenizer", + kind="codec", + aliases=("audio_tokenizer", "moss_codec", "codec"), + ), +} + + +def _normalize_name(value: str) -> str: + return str(value).strip().lower().replace("-", "_") + + +def _build_lookup_table() -> Dict[str, str]: + lookup: Dict[str, str] = {} + for key, spec in MOSS_CATALOG.items(): + lookup[_normalize_name(key)] = key + lookup[_normalize_name(spec.hf_id)] = key + for alias in spec.aliases: + lookup[_normalize_name(alias)] = key + return lookup + + +def _split_model_args(values: Sequence[str]) -> List[str]: + entries: List[str] = [] + for raw in values: + for part in str(raw).split(","): + candidate = part.strip() + if candidate: + entries.append(candidate) + return entries + + +def _resolve_selection(args: argparse.Namespace) -> List[MossModelSpec]: + if not args.all and not args.models: + raise ValueError("Specify --models or --all") + + lookup = _build_lookup_table() + selected_keys: List[str] = [] + + if args.all: + selected_keys.extend(MOSS_CATALOG.keys()) + + if args.models: + for entry in _split_model_args(args.models): + normalized = _normalize_name(entry) + if normalized not in lookup: + known = ", ".join(sorted(MOSS_CATALOG.keys())) + raise ValueError( + f"Unknown model selection '{entry}'. Known keys: {known}" + ) + selected_keys.append(lookup[normalized]) + + deduped_keys: List[str] = [] + seen = set() + for key in selected_keys: + if key in seen: + continue + seen.add(key) + deduped_keys.append(key) + + return [MOSS_CATALOG[key] for key in deduped_keys] + + +def _resolve_output_suffix(args: argparse.Namespace) -> str: + suffix_parts: List[str] = [] + if args.quantize: + suffix_parts.append(f"{args.q_bits}bit") + elif args.dequantize: + suffix_parts.append("dequantized") + elif args.dtype: + suffix_parts.append(args.dtype) + + if not suffix_parts: + return "" + return "-" + "-".join(suffix_parts) + + +def _prepare_output_dir( + base_output: Path, + spec: MossModelSpec, + *, + suffix: str, + skip_existing: bool, + overwrite: bool, +) -> tuple[Path, bool]: + out_dir = base_output / f"{spec.key}{suffix}" + + if out_dir.exists() and any(out_dir.iterdir()): + if skip_existing: + return out_dir, True + if overwrite: + shutil.rmtree(out_dir) + else: + raise FileExistsError( + f"Output directory exists and is not empty: {out_dir}. " + "Use --skip-existing or --overwrite." + ) + + out_dir.mkdir(parents=True, exist_ok=True) + return out_dir, False + + +def _convert_tts_model( + spec: MossModelSpec, + *, + output_dir: Path, + args: argparse.Namespace, +) -> None: + convert_generic_model( + hf_path=spec.hf_id, + mlx_path=str(output_dir), + quantize=bool(args.quantize), + q_group_size=int(args.q_group_size), + q_bits=int(args.q_bits), + dtype=args.dtype, + upload_repo=None, + revision=args.revision, + dequantize=bool(args.dequantize), + quant_predicate=args.quant_predicate, + model_domain="tts", + ) + + +def _copy_support_files(source_dir: Path, output_dir: Path) -> None: + for pattern in ("*.json", "*.txt", "*.md", "*.py"): + for src in source_dir.glob(pattern): + if src.name in {"config.json", "model.safetensors"}: + continue + shutil.copy2(src, output_dir / src.name) + + +def _convert_moss_audio_tokenizer( + spec: MossModelSpec, + *, + output_dir: Path, + args: argparse.Namespace, +) -> None: + if args.dequantize: + raise ValueError("--dequantize is not supported for moss_audio_tokenizer") + if args.quantize: + print( + "[WARN] --quantize is not currently supported for moss_audio_tokenizer; " + "exporting full-precision weights instead." + ) + + source_dir = get_model_path(spec.hf_id, revision=args.revision) + model = MossAudioTokenizer.from_pretrained(source_dir, strict=True) + + if args.dtype: + target_dtype = getattr(mx, args.dtype) + casted = [ + (name, value.astype(target_dtype)) + for name, value in tree_flatten(model.parameters()) + ] + model.load_weights(casted, strict=False) + + model.save_config(output_dir / "config.json") + mx.save_safetensors( + str(output_dir / "model.safetensors"), + dict(tree_flatten(model.parameters())), + metadata={"format": "mlx"}, + ) + _copy_support_files(source_dir, output_dir) + + +def _list_models() -> None: + print("Available MOSS models:") + for key, spec in MOSS_CATALOG.items(): + aliases = ", ".join(spec.aliases) if spec.aliases else "-" + print( + f"- {key:22s} | kind={spec.kind:5s} | hf_id={spec.hf_id} | aliases={aliases}" + ) + + +def configure_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Convert MOSS models to MLX format") + parser.add_argument( + "--models", + nargs="+", + default=None, + help=( + "Model keys/aliases/HF ids to convert. Supports spaces and comma-separated " + "values." + ), + ) + parser.add_argument( + "--all", + action="store_true", + help="Convert all known MOSS models in the catalog", + ) + parser.add_argument( + "--list-models", + action="store_true", + help="Print model catalog and exit", + ) + parser.add_argument( + "--output-root", + type=Path, + default=Path("converted") / "moss", + help="Base output directory for converted models", + ) + parser.add_argument( + "--revision", + type=str, + default=None, + help="Optional HF revision for all selected models", + ) + parser.add_argument( + "--dtype", + type=str, + choices=MODEL_CONVERSION_DTYPES, + default=None, + help="Target output dtype", + ) + parser.add_argument( + "--quantize", + action="store_true", + help="Quantize TTS models during conversion", + ) + parser.add_argument( + "--q-group-size", + type=int, + default=64, + help="Quantization group size", + ) + parser.add_argument( + "--q-bits", + type=int, + default=4, + help="Quantization bits", + ) + parser.add_argument( + "--quant-predicate", + type=str, + choices=QUANT_RECIPES, + default=None, + help="Mixed-bit quantization recipe for TTS models", + ) + parser.add_argument( + "--dequantize", + action="store_true", + help="Dequantize selected models (TTS only)", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print conversion plan without executing", + ) + parser.add_argument( + "--skip-existing", + action="store_true", + help="Skip models whose output directories already exist and are non-empty", + ) + parser.add_argument( + "--overwrite", + action="store_true", + help="Delete existing per-model output directory before conversion", + ) + parser.add_argument( + "--continue-on-error", + action="store_true", + help="Continue remaining conversions when one model fails", + ) + return parser + + +def main() -> None: + parser = configure_parser() + args = parser.parse_args() + + if args.list_models: + _list_models() + return + + selected_specs = _resolve_selection(args) + suffix = _resolve_output_suffix(args) + args.output_root.mkdir(parents=True, exist_ok=True) + + print("Conversion plan:") + for spec in selected_specs: + print(f"- {spec.key} ({spec.kind}) -> {spec.hf_id}") + + if args.dry_run: + print("Dry run requested; exiting without conversion.") + return + + successes: List[str] = [] + skipped: List[str] = [] + failures: List[tuple[str, str]] = [] + + for spec in selected_specs: + try: + output_dir, was_skipped = _prepare_output_dir( + args.output_root, + spec, + suffix=suffix, + skip_existing=bool(args.skip_existing), + overwrite=bool(args.overwrite), + ) + if was_skipped: + skipped.append(spec.key) + print(f"[SKIP] {spec.key}: output already exists ({output_dir})") + continue + + print(f"[RUN ] {spec.key} -> {output_dir}") + if spec.kind == "tts": + _convert_tts_model(spec, output_dir=output_dir, args=args) + elif spec.kind == "codec": + _convert_moss_audio_tokenizer(spec, output_dir=output_dir, args=args) + else: + raise ValueError(f"Unsupported model kind '{spec.kind}'") + + successes.append(spec.key) + print(f"[ OK ] {spec.key}") + except Exception as exc: + failures.append((spec.key, str(exc))) + print(f"[FAIL] {spec.key}: {exc}") + if not args.continue_on_error: + break + + print("\nSummary:") + print(f"- Succeeded: {len(successes)}") + if successes: + print(f" {', '.join(successes)}") + print(f"- Skipped: {len(skipped)}") + if skipped: + print(f" {', '.join(skipped)}") + print(f"- Failed: {len(failures)}") + for key, message in failures: + print(f" - {key}: {message}") + + if failures: + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/tests/fixtures/moss/MOSS-Audio-Tokenizer/config.json b/tests/fixtures/moss/MOSS-Audio-Tokenizer/config.json new file mode 100644 index 000000000..3c7821f6f --- /dev/null +++ b/tests/fixtures/moss/MOSS-Audio-Tokenizer/config.json @@ -0,0 +1,51 @@ +{ + "model_type": "speech_tokenizer", + "sampling_rate": 24000, + "downsample_rate": 1920, + "causal_transformer_context_duration": 10, + "encoder_kwargs": [ + { + "module_type": "PatchedPretransform", + "patch_size": 240 + }, + { + "module_type": "PatchedPretransform", + "patch_size": 2 + }, + { + "module_type": "PatchedPretransform", + "patch_size": 2 + }, + { + "module_type": "PatchedPretransform", + "patch_size": 2 + } + ], + "decoder_kwargs": [ + { + "module_type": "PatchedPretransform", + "patch_size": 2 + }, + { + "module_type": "PatchedPretransform", + "patch_size": 2 + }, + { + "module_type": "PatchedPretransform", + "patch_size": 2 + }, + { + "module_type": "PatchedPretransform", + "patch_size": 240 + } + ], + "quantizer_kwargs": { + "input_dim": 768, + "rvq_dim": 512, + "output_dim": 768, + "num_quantizers": 32, + "codebook_size": 1024, + "codebook_dim": 8, + "quantizer_type": "rlfq" + } +} diff --git a/tests/fixtures/moss/MOSS-TTS-HF-Repos/MOSS-SoundEffect/config.json b/tests/fixtures/moss/MOSS-TTS-HF-Repos/MOSS-SoundEffect/config.json new file mode 100644 index 000000000..8d476248d --- /dev/null +++ b/tests/fixtures/moss/MOSS-TTS-HF-Repos/MOSS-SoundEffect/config.json @@ -0,0 +1,7 @@ +{ + "model_type": "moss_tts_delay", + "sampling_rate": 24000, + "audio_vocab_size": 1024, + "n_vq": 16, + "vocab_size": 155648 +} diff --git a/tests/fixtures/moss/MOSS-TTS-HF-Repos/MOSS-TTS-Local-Transformer/config.json b/tests/fixtures/moss/MOSS-TTS-HF-Repos/MOSS-TTS-Local-Transformer/config.json new file mode 100644 index 000000000..88b35b2dd --- /dev/null +++ b/tests/fixtures/moss/MOSS-TTS-HF-Repos/MOSS-TTS-Local-Transformer/config.json @@ -0,0 +1,8 @@ +{ + "model_type": "moss_tts_delay", + "sampling_rate": 24000, + "audio_vocab_size": 1024, + "n_vq": 32, + "vocab_size": 155648, + "local_num_layers": 4 +} diff --git a/tests/fixtures/moss/MOSS-TTS-HF-Repos/MOSS-TTS-Realtime/config.json b/tests/fixtures/moss/MOSS-TTS-HF-Repos/MOSS-TTS-Realtime/config.json new file mode 100644 index 000000000..4c5c65342 --- /dev/null +++ b/tests/fixtures/moss/MOSS-TTS-HF-Repos/MOSS-TTS-Realtime/config.json @@ -0,0 +1,6 @@ +{ + "model_type": "moss_tts_realtime", + "audio_vocab_size": 1027, + "rvq": 16, + "vocab_size": 151936 +} diff --git a/tests/fixtures/moss/MOSS-TTS-HF-Repos/MOSS-TTS/config.json b/tests/fixtures/moss/MOSS-TTS-HF-Repos/MOSS-TTS/config.json new file mode 100644 index 000000000..682567d66 --- /dev/null +++ b/tests/fixtures/moss/MOSS-TTS-HF-Repos/MOSS-TTS/config.json @@ -0,0 +1,7 @@ +{ + "model_type": "moss_tts_delay", + "sampling_rate": 24000, + "audio_vocab_size": 1024, + "n_vq": 32, + "vocab_size": 155648 +} diff --git a/tests/fixtures/moss/MOSS-TTS-HF-Repos/MOSS-TTSD-v1.0/config.json b/tests/fixtures/moss/MOSS-TTS-HF-Repos/MOSS-TTSD-v1.0/config.json new file mode 100644 index 000000000..8d476248d --- /dev/null +++ b/tests/fixtures/moss/MOSS-TTS-HF-Repos/MOSS-TTSD-v1.0/config.json @@ -0,0 +1,7 @@ +{ + "model_type": "moss_tts_delay", + "sampling_rate": 24000, + "audio_vocab_size": 1024, + "n_vq": 16, + "vocab_size": 155648 +} diff --git a/tests/fixtures/moss/MOSS-TTS-HF-Repos/MOSS-Voice-Generator/config.json b/tests/fixtures/moss/MOSS-TTS-HF-Repos/MOSS-Voice-Generator/config.json new file mode 100644 index 000000000..8d476248d --- /dev/null +++ b/tests/fixtures/moss/MOSS-TTS-HF-Repos/MOSS-Voice-Generator/config.json @@ -0,0 +1,7 @@ +{ + "model_type": "moss_tts_delay", + "sampling_rate": 24000, + "audio_vocab_size": 1024, + "n_vq": 16, + "vocab_size": 155648 +} diff --git a/tests/fixtures/moss/README.md b/tests/fixtures/moss/README.md new file mode 100644 index 000000000..166b86d32 --- /dev/null +++ b/tests/fixtures/moss/README.md @@ -0,0 +1,11 @@ +# MOSS Contract Fixtures + +These JSON fixtures are a minimal, stable contract surface for MOSS audit tests. + +- They intentionally do not depend on local `REFERENCE/` checkouts. +- They only include keys consumed by: + - `mlx_audio.codec.models.moss_audio_tokenizer.config.load_moss_audio_tokenizer_config` + - `mlx_audio.tts.models.moss_tts.audit.load_moss_variant_invariants` + - `mlx_audio.tts.models.moss_tts.audit.load_moss_audio_tokenizer_audit` + +If contract expectations change, update these fixtures and the corresponding tests together. diff --git a/uv.lock b/uv.lock index f2b0ef96c..1989d1709 100644 --- a/uv.lock +++ b/uv.lock @@ -128,11 +128,11 @@ wheels = [ [[package]] name = "babel" -version = "2.17.0" +version = "2.18.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" }, + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, ] [[package]] @@ -625,35 +625,36 @@ wheels = [ [[package]] name = "fastapi" -version = "0.128.0" +version = "0.129.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, { name = "pydantic" }, { name = "starlette" }, { name = "typing-extensions" }, + { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/52/08/8c8508db6c7b9aae8f7175046af41baad690771c9bcde676419965e338c7/fastapi-0.128.0.tar.gz", hash = "sha256:1cc179e1cef10a6be60ffe429f79b829dce99d8de32d7acb7e6c8dfdf7f2645a", size = 365682, upload-time = "2025-12-27T15:21:13.714Z" } +sdist = { url = "https://files.pythonhosted.org/packages/48/47/75f6bea02e797abff1bca968d5997793898032d9923c1935ae2efdece642/fastapi-0.129.0.tar.gz", hash = "sha256:61315cebd2e65df5f97ec298c888f9de30430dd0612d59d6480beafbc10655af", size = 375450, upload-time = "2026-02-12T13:54:52.541Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/05/5cbb59154b093548acd0f4c7c474a118eda06da25aa75c616b72d8fcd92a/fastapi-0.128.0-py3-none-any.whl", hash = "sha256:aebd93f9716ee3b4f4fcfe13ffb7cf308d99c9f3ab5622d8877441072561582d", size = 103094, upload-time = "2025-12-27T15:21:12.154Z" }, + { url = "https://files.pythonhosted.org/packages/9e/dd/d0ee25348ac58245ee9f90b6f3cbb666bf01f69be7e0911f9851bddbda16/fastapi-0.129.0-py3-none-any.whl", hash = "sha256:b4946880e48f462692b31c083be0432275cbfb6e2274566b1be91479cc1a84ec", size = 102950, upload-time = "2026-02-12T13:54:54.528Z" }, ] [[package]] name = "filelock" -version = "3.20.3" +version = "3.24.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/8a/b24ff2c2d7f20ce930b5efe91e7260247d185d8939707721168ad204e465/filelock-3.24.1.tar.gz", hash = "sha256:3440181dd03f8904c108c8e9f5b11d1663e9fc960f1c837586a11f1c5c041e54", size = 37452, upload-time = "2026-02-15T22:03:16.564Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" }, + { url = "https://files.pythonhosted.org/packages/97/64/3613e89811e79aca8d0d4f2c984fc66336bc9d83529c1cbe02f5df010d0a/filelock-3.24.1-py3-none-any.whl", hash = "sha256:7c59f595e3cf4887dc95b403a896849da49ed183d7c9d7ee855646ca99f10698", size = 24153, upload-time = "2026-02-15T22:03:15.262Z" }, ] [[package]] name = "fsspec" -version = "2026.1.0" +version = "2026.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d5/7d/5df2650c57d47c57232af5ef4b4fdbff182070421e405e0d62c6cdbfaa87/fsspec-2026.1.0.tar.gz", hash = "sha256:e987cb0496a0d81bba3a9d1cee62922fb395e7d4c3b575e57f547953334fe07b", size = 310496, upload-time = "2026-01-09T15:21:35.562Z" } +sdist = { url = "https://files.pythonhosted.org/packages/51/7c/f60c259dcbf4f0c47cc4ddb8f7720d2dcdc8888c8e5ad84c73ea4531cc5b/fsspec-2026.2.0.tar.gz", hash = "sha256:6544e34b16869f5aacd5b90bdf1a71acb37792ea3ddf6125ee69a22a53fb8bff", size = 313441, upload-time = "2026-02-05T21:50:53.743Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/01/c9/97cc5aae1648dcb851958a3ddf73ccd7dbe5650d95203ecb4d7720b4cdbf/fsspec-2026.1.0-py3-none-any.whl", hash = "sha256:cb76aa913c2285a3b49bdd5fc55b1d7c708d7208126b60f2eb8194fe1b4cbdcc", size = 201838, upload-time = "2026-01-09T15:21:34.041Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437", size = 202505, upload-time = "2026-02-05T21:50:51.819Z" }, ] [[package]] @@ -724,7 +725,7 @@ wheels = [ [[package]] name = "huggingface-hub" -version = "1.3.3" +version = "1.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, @@ -738,9 +739,9 @@ dependencies = [ { name = "typer-slim" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/02/c3/544cd4cdd4b3c6de8591b56bb69efc3682e9ac81e36135c02e909dd98c5b/huggingface_hub-1.3.3.tar.gz", hash = "sha256:f8be6f468da4470db48351e8c77d6d8115dff9b3daeb30276e568767b1ff7574", size = 627649, upload-time = "2026-01-22T13:59:46.931Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/fc/eb9bc06130e8bbda6a616e1b80a7aa127681c448d6b49806f61db2670b61/huggingface_hub-1.4.1.tar.gz", hash = "sha256:b41131ec35e631e7383ab26d6146b8d8972abc8b6309b963b306fbcca87f5ed5", size = 642156, upload-time = "2026-02-06T09:20:03.013Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/48/e8/0d032698916b9773b710c46e3b8e0154fc34cd017b151cc316c84c6c34fe/huggingface_hub-1.3.3-py3-none-any.whl", hash = "sha256:44af7b62380efc87c1c3bde7e1bf0661899b5bdfca1fc60975c61ee68410e10e", size = 536604, upload-time = "2026-01-22T13:59:45.391Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ae/2f6d96b4e6c5478d87d606a1934b5d436c4a2bce6bb7c6fdece891c128e3/huggingface_hub-1.4.1-py3-none-any.whl", hash = "sha256:9931d075fb7a79af5abc487106414ec5fba2c0ae86104c0c62fd6cae38873d18", size = 553326, upload-time = "2026-02-06T09:20:00.728Z" }, ] [[package]] @@ -914,6 +915,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/a8/e61a8c2b3cc7a597073d9cde1fcbb567e9d827f1db30c93cf80422eac70d/llvmlite-0.46.0-cp314-cp314-win_amd64.whl", hash = "sha256:7821eda3ec1f18050f981819756631d60b6d7ab1a6cf806d9efefbe3f4082d61", size = 39153056, upload-time = "2025-12-08T18:15:33.938Z" }, ] +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -999,6 +1012,15 @@ wheels = [ { 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 = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "miniaudio" version = "1.61" @@ -1054,7 +1076,7 @@ wheels = [ [[package]] name = "mistral-common" -version = "1.8.8" +version = "1.9.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jsonschema" }, @@ -1067,9 +1089,9 @@ dependencies = [ { name = "tiktoken" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0a/bb/6fc2e46d9920c80f0d053d58be5b0546c18010ff3a5f9b9d91299226e989/mistral_common-1.8.8.tar.gz", hash = "sha256:8ae28b3f88bce1b9396f5d1107e5ea87e4130486b9f6d811df6d5ac07bff2186", size = 6337014, upload-time = "2025-12-22T10:51:47.245Z" } +sdist = { url = "https://files.pythonhosted.org/packages/db/ce/685b8127a326478e05501cb4c9ca23d1cd9f37e16c465a1e832c75aea709/mistral_common-1.9.1.tar.gz", hash = "sha256:550583d70a395c3586cfb748ffab53bd1d7c3409507f0efc0118bff30ffb26e9", size = 6338922, upload-time = "2026-02-12T10:53:41.639Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/73/02/c1866598c8e94a4d0593b73e6dec0afea722227b9b3223bf6bb8ab269fa7/mistral_common-1.8.8-py3-none-any.whl", hash = "sha256:f63ce79b1867b3fc7c8b66fcaedab3b07966185567558038dc02321c17e4f39f", size = 6518005, upload-time = "2025-12-22T10:51:44.88Z" }, + { url = "https://files.pythonhosted.org/packages/ac/72/a38bb1fd9fd4d4ef990341c9dd1a7c8061f1951e10efa6d50c0a3f04eced/mistral_common-1.9.1-py3-none-any.whl", hash = "sha256:9e2b2520b6f67bac2e2bb06fcf985b7a1277b01938da2b7cda8cf0fdbfa92e91", size = 6518623, upload-time = "2026-02-12T10:53:39.457Z" }, ] [package.optional-dependencies] @@ -1080,37 +1102,37 @@ audio = [ [[package]] name = "mlx" -version = "0.30.3" +version = "0.30.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mlx-metal", marker = "sys_platform == 'darwin'" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/9c/d6f72f04eeeeaeee8309397efcfa0e923189d0b720f4ac6b3887d0a2f40b/mlx-0.30.3-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:685051761e428336f8f19ae76a761ce99d29ff67c52738f15ce6409e2ff34e6b", size = 568453, upload-time = "2026-01-14T01:16:40.796Z" }, - { url = "https://files.pythonhosted.org/packages/db/59/505717fd63f62d766f054ab8770d08e98b10217c0995bd2555429863fd31/mlx-0.30.3-cp310-cp310-macosx_15_0_arm64.whl", hash = "sha256:e405e6575e3b0b00dd6bd02bdb415b638cd5c2e5faedb696df2b2c8fbe871240", size = 568451, upload-time = "2026-01-14T01:16:42.027Z" }, - { url = "https://files.pythonhosted.org/packages/86/9c/a5319ae8ed0baa76fde80def12391ae13acec1b88904d4ead9bbabc9a083/mlx-0.30.3-cp310-cp310-macosx_26_0_arm64.whl", hash = "sha256:46894eb528457483aec44227f61afdff424cb76d146a6e1727d03ea0f52be41b", size = 568309, upload-time = "2026-01-14T05:52:06.915Z" }, - { url = "https://files.pythonhosted.org/packages/8c/7c/ac360b04c6b09acf11fcb54068909ca030325b248557930f6991d5601436/mlx-0.30.3-cp310-cp310-manylinux_2_35_aarch64.whl", hash = "sha256:a18e1b380130a77feda83b8bb8a7ff5a24f78e7263af484c6627f23e4210bdaf", size = 631435, upload-time = "2026-01-14T01:16:43.085Z" }, - { url = "https://files.pythonhosted.org/packages/01/b5/3becb2c93955d43539d9c916b33899a57c50099c29310dc2b5c68ff7a88d/mlx-0.30.3-cp310-cp310-manylinux_2_35_x86_64.whl", hash = "sha256:c27f8e78b89cf97411d740a2ca46accf6c6e3fcc43d1e906389abff1f0e00376", size = 664899, upload-time = "2026-01-14T01:16:44.623Z" }, - { url = "https://files.pythonhosted.org/packages/78/b6/dfcfffc41d832a86249715fab336dc8638c2237035287eb24af792484c53/mlx-0.30.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:794e79587a4906bdb3c5473ef936f45008eaaa609a3c498cc29a442b2c829621", size = 568664, upload-time = "2026-01-14T01:16:45.573Z" }, - { url = "https://files.pythonhosted.org/packages/22/9f/22d494b83b611380063da31c2b482db8c620f7ad6531cfcd1e11f7c35852/mlx-0.30.3-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:472cdc6eaca8610224621a1561e8c36477eab1a2f0dd3eb49b95484d739c4605", size = 568663, upload-time = "2026-01-14T01:16:46.588Z" }, - { url = "https://files.pythonhosted.org/packages/0d/76/b6fb0500aef8e9ed65d4730d8c34b13d7a770ca863b9af363b5713a16040/mlx-0.30.3-cp311-cp311-macosx_26_0_arm64.whl", hash = "sha256:a5d82be69c7e671dc4d5855d2f6aedcb507817e5985478903ab754b642d9ba01", size = 568522, upload-time = "2026-01-14T05:52:08.334Z" }, - { url = "https://files.pythonhosted.org/packages/6e/23/ea140c35419ec133e1037d34d94854474cdd72c89eedc3a90b8ec65fb0ff/mlx-0.30.3-cp311-cp311-manylinux_2_35_aarch64.whl", hash = "sha256:009a9a1d2e234b9b269f455729202feaf22eb1faf2c7b85818f2473f6c2f9cbe", size = 632235, upload-time = "2026-01-14T01:16:47.764Z" }, - { url = "https://files.pythonhosted.org/packages/af/18/335d2d455b1e15036e315c6b64de8e6b4b04ec60576e1b99a651a7487014/mlx-0.30.3-cp311-cp311-manylinux_2_35_x86_64.whl", hash = "sha256:ba7141b6c251207d26a5611a0038d121cd13e367a59589d8c827e6af06b1f406", size = 664821, upload-time = "2026-01-14T01:16:48.8Z" }, - { url = "https://files.pythonhosted.org/packages/11/b3/e24c3a69dad0cf4404bb174c6fed0d804022da64758cd815a254e1cd0627/mlx-0.30.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:0b275168b80645a155b456e1a457a37fb5ee2c251e8fbd8db9e153351a9e2d2f", size = 569398, upload-time = "2026-01-14T01:16:49.804Z" }, - { url = "https://files.pythonhosted.org/packages/0b/87/d0804443da97a06d3439f6efb0ceffa178f530a121f0f4a6c77b39f8bfd7/mlx-0.30.3-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:6e818de14864982e832344198240a1dafba7d3316c4eb6f1b8e43b4dd25dd2ef", size = 569396, upload-time = "2026-01-14T01:16:51.007Z" }, - { url = "https://files.pythonhosted.org/packages/cf/dc/7cdd95e4561b73fba8c86bf11293797076120400e472fe2a72ef483b6d8d/mlx-0.30.3-cp312-cp312-macosx_26_0_arm64.whl", hash = "sha256:d23b422209fd4b7ecacef59070321f8c6a122f906a5e9b6683a5fc9e1b8fcd5c", size = 569192, upload-time = "2026-01-14T05:52:09.715Z" }, - { url = "https://files.pythonhosted.org/packages/58/3b/6892f48dce949da7e1706cad45a1693857ef3adf23f849bf851c37e605eb/mlx-0.30.3-cp312-cp312-manylinux_2_35_aarch64.whl", hash = "sha256:f487461ffd5c2411c012dd8cd0d347dd807f05f223b1dec1c13bad0815cdcefd", size = 617390, upload-time = "2026-01-14T01:16:52.676Z" }, - { url = "https://files.pythonhosted.org/packages/66/ce/606e2111bc7c2ed1a2f2582caeb3e73b90e00d773d573fe9cd5dd36a0321/mlx-0.30.3-cp312-cp312-manylinux_2_35_x86_64.whl", hash = "sha256:b78627f324790fd0e06c4fa6e79b88094b342c5c425f8909de7c3f2fa5d01302", size = 659552, upload-time = "2026-01-14T01:16:53.888Z" }, - { url = "https://files.pythonhosted.org/packages/d0/22/42935d593fe82d3b98eb9d60e4620ed99703886635106f89d407c68f33bc/mlx-0.30.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:743fac1e4f9e8e46c8262943c643a31139c255cdb256c99ad496958215ccac1e", size = 569344, upload-time = "2026-01-14T01:16:54.847Z" }, - { url = "https://files.pythonhosted.org/packages/7d/27/f2e7a5236289d45315d0215e8553b4dd7e2faaba3bcb5025b34b25d5ab66/mlx-0.30.3-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:3b04ae81655aa0e63a6e8f2c749de3bbce64cf5b168ae10f39ed086dfa99e7f8", size = 569345, upload-time = "2026-01-14T01:16:56.564Z" }, - { url = "https://files.pythonhosted.org/packages/01/41/06b042457f51952456e9bb46b2c6e205ab3a28fc52d6751b5787fdb762b2/mlx-0.30.3-cp313-cp313-macosx_26_0_arm64.whl", hash = "sha256:ba9b5bdb1e929cc130af72efd7f73508c0f4e526d224489af7ec1c6419564659", size = 569213, upload-time = "2026-01-14T05:52:10.86Z" }, - { url = "https://files.pythonhosted.org/packages/ec/1e/f62c98fc0d2d878ee4235671f9d406b13cc9240493ba6fcfde2f72c2ff83/mlx-0.30.3-cp313-cp313-manylinux_2_35_aarch64.whl", hash = "sha256:dfe5c5b64e55398a22100804abbf9681996b03129e720e36b1727ed704db12b5", size = 617309, upload-time = "2026-01-14T01:16:57.58Z" }, - { url = "https://files.pythonhosted.org/packages/e9/62/811f064693449de740350d27793ce39343a460305ec8d878c318b80921d0/mlx-0.30.3-cp313-cp313-manylinux_2_35_x86_64.whl", hash = "sha256:a3364924610929936e6aaf13c71106161258e5a5d3f7813a64c07cc2435f9f55", size = 659521, upload-time = "2026-01-14T01:16:58.719Z" }, - { url = "https://files.pythonhosted.org/packages/82/e2/6e551bd48fb350fbf0ee4cc5cd09485437d260b8f4937f22d8623e14687a/mlx-0.30.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2c27fd8daaae14ca6cf407fcd236006a6e968f7708c8f61a2709116f2e754852", size = 571920, upload-time = "2026-01-14T01:16:59.683Z" }, - { url = "https://files.pythonhosted.org/packages/82/c0/561d1c9d3d12830b0e7fdcbd807585ef20909e398d4bcdbf25e4367543eb/mlx-0.30.3-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:b755fd4ed4b6a2ae4dee3766b5a2ea52fcbe83ebd1cf018458e18b74139409f3", size = 571921, upload-time = "2026-01-14T01:17:00.868Z" }, - { url = "https://files.pythonhosted.org/packages/42/1a/fb573fc2edc22a777fa254ff5c0c886ffd2c88aeb1f21c45778ef170f990/mlx-0.30.3-cp314-cp314-macosx_26_0_arm64.whl", hash = "sha256:7e352c0369a2f7e54d4f317b434eab3333918ea9edde1c43c61d36386b6f76bf", size = 571732, upload-time = "2026-01-14T05:52:11.893Z" }, - { url = "https://files.pythonhosted.org/packages/9e/db/d0083e8f2205b3b2dcd9670eb6f0d6c1b7cbfea6b01a1f8bff39142edf44/mlx-0.30.3-cp314-cp314-manylinux_2_35_aarch64.whl", hash = "sha256:00ac867f3d003c1477a66a579442c2040ba7ea43ce3c174490d1f8bf379606bd", size = 619635, upload-time = "2026-01-14T01:17:01.812Z" }, - { url = "https://files.pythonhosted.org/packages/ab/90/ab0b93ff0e76da4fe0e878722c76a308cfb950b044a4676e9617276d8ccd/mlx-0.30.3-cp314-cp314-manylinux_2_35_x86_64.whl", hash = "sha256:5be7d0329036f09c6ed003ea3e307e97e3144f20a3e4711b01810d7d5013cf2c", size = 659652, upload-time = "2026-01-14T01:17:02.915Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2e/016527cf1012a68bb25f1ba3a73914f87807a7fee58d7a54fa69adcd2f55/mlx-0.30.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:6c4df52aebfac40563259c04fca4a0c4d05b2061e09cdaad24e4233baa560b4f", size = 573214, upload-time = "2026-02-06T03:45:00.344Z" }, + { url = "https://files.pythonhosted.org/packages/a4/8f/600c6bed6eb6574e4a9d15e7a20a2ec903c2c5b54e2fd782c592a00ff933/mlx-0.30.6-cp310-cp310-macosx_15_0_arm64.whl", hash = "sha256:0df8715b5cb84b6b6314aa868302873a0a94e63e6d195bc9858b8c58c79aa5a4", size = 573213, upload-time = "2026-02-06T03:45:02.208Z" }, + { url = "https://files.pythonhosted.org/packages/11/f7/d15af26c639c3d6000b6478fc0d54a7a528d71e79255190a0abc42f31608/mlx-0.30.6-cp310-cp310-macosx_26_0_arm64.whl", hash = "sha256:7b4742ec2b748d2406c884e364fcd6f89d7f2b3f834f7b65c4c07acfa139cae8", size = 573254, upload-time = "2026-02-06T03:45:03.575Z" }, + { url = "https://files.pythonhosted.org/packages/d4/c3/e4f1fda18068fe0d5213f67d94771f39e219a24072746a02ca70a3a6020f/mlx-0.30.6-cp310-cp310-manylinux_2_35_aarch64.whl", hash = "sha256:45c91ff34690b0d34063d1dc68a7a87f142ff9c5df6e5c611884a6bdcc9a53e1", size = 636558, upload-time = "2026-02-06T03:45:05.262Z" }, + { url = "https://files.pythonhosted.org/packages/70/c7/201e9e3ab3304aca99f850a0c1bc5d52e52e48960b0d415a196cd288faef/mlx-0.30.6-cp310-cp310-manylinux_2_35_x86_64.whl", hash = "sha256:b9b746fa0a44dfe1576925eb343ee9afa7023d3d805f84a3d90d0066096f31b8", size = 669479, upload-time = "2026-02-06T03:45:07.122Z" }, + { url = "https://files.pythonhosted.org/packages/93/81/21d745beeda53ee29e9c027d806f1e1cac983e8ddb3d6b18d44a1b30a11b/mlx-0.30.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:e721d29c4250ada3cba7a5ad43d358b42401600e792c378ed6b52c9d692aaba8", size = 573359, upload-time = "2026-02-06T03:45:08.41Z" }, + { url = "https://files.pythonhosted.org/packages/05/08/826286458df5ea91efc380d71fd8058ee7338207c6b547204f2758e168d8/mlx-0.30.6-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:23f55c1c160a38ab350f4f7ce3ab10c490df39800ad35c4821c3ef5fa89ec24e", size = 573359, upload-time = "2026-02-06T03:45:09.688Z" }, + { url = "https://files.pythonhosted.org/packages/56/aa/3fc9ac795934182e680a0cbeb99202838e4548139cfd580015dcfbfb7ee8/mlx-0.30.6-cp311-cp311-macosx_26_0_arm64.whl", hash = "sha256:37c37571f8c1567c2b7e4871237b92a2b321fb8157d6426373be946c03e49ebd", size = 573406, upload-time = "2026-02-06T03:45:11.383Z" }, + { url = "https://files.pythonhosted.org/packages/af/d1/b8bcc332e3c268bf59632d7a8f1b5c8e6a4b154d651aa20b93e359e3c004/mlx-0.30.6-cp311-cp311-manylinux_2_35_aarch64.whl", hash = "sha256:253317a2bab3a1927d7cb89267690d82525acb5810f30d696ff9b705e7f8a78a", size = 636997, upload-time = "2026-02-06T03:45:12.619Z" }, + { url = "https://files.pythonhosted.org/packages/89/fa/bdc4b8aa6d078e724decb754b0f04ac1a25e46c190e52639906401c3b8b8/mlx-0.30.6-cp311-cp311-manylinux_2_35_x86_64.whl", hash = "sha256:4e2058ac219d99d38baa90f810947c6bfa09a28511dfe660629012a7c470c35d", size = 669638, upload-time = "2026-02-06T03:45:14.103Z" }, + { url = "https://files.pythonhosted.org/packages/85/fe/85acff870a9949494fd505b22c34d63eb127442f5f8751a159d3a78f7ef6/mlx-0.30.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:47d20016cb5733d06c1d017412a31983dbe3237cf70942760430188922ffc1ba", size = 573484, upload-time = "2026-02-06T03:45:15.88Z" }, + { url = "https://files.pythonhosted.org/packages/e1/14/5546082ee37118b33afb6300d8e07d03efea2dbba838d514d9465f87489b/mlx-0.30.6-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:6b8c133df2d6a2ed173d2b7bb50d7032a13be84e1792b7d79171ad8f50a8c0ea", size = 573486, upload-time = "2026-02-06T03:45:17.506Z" }, + { url = "https://files.pythonhosted.org/packages/ef/b5/ae04666a7b8bda74e2c6903756710103e283ea6fa4edd2c92449ad4547d6/mlx-0.30.6-cp312-cp312-macosx_26_0_arm64.whl", hash = "sha256:31eabb5d1da4ac7b16f2042fdb046b993cdf0f32bc3312e0af469232bb67720b", size = 573509, upload-time = "2026-02-06T03:45:18.68Z" }, + { url = "https://files.pythonhosted.org/packages/9f/8e/fdee70051e2c7f523f9b22575f05bdb1b47300aba1ecda15bda98a9b01c1/mlx-0.30.6-cp312-cp312-manylinux_2_35_aarch64.whl", hash = "sha256:070010932d424005e6c9c76b379ccdf4d96b385658fdb34dc780fa4eb24cb1a0", size = 622061, upload-time = "2026-02-06T03:45:19.984Z" }, + { url = "https://files.pythonhosted.org/packages/65/dd/fe29f1e19e5268a8f892c83be35f14e63f1aea3baf7e7e44e246d4fea184/mlx-0.30.6-cp312-cp312-manylinux_2_35_x86_64.whl", hash = "sha256:9084c8f20544ec6a53aa3edcd2da85d205e07ff80bd47151633219bd5cfcd23c", size = 663715, upload-time = "2026-02-06T03:45:21.873Z" }, + { url = "https://files.pythonhosted.org/packages/ae/5b/e460e144a34d5529e010056cccf50b538d56ed001473bc6b246018fd58cb/mlx-0.30.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:ed86f8bffc174c2f259ca589ea25464c96cf69d1bb457074a2bf2ef53737e54f", size = 573515, upload-time = "2026-02-06T03:45:23.405Z" }, + { url = "https://files.pythonhosted.org/packages/60/25/69833fefb9a3fef30b56792b1bcd022496c4fea83e45411d289b77ef7546/mlx-0.30.6-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:c52294958269e20f300639a17c1900ca8fc737d859ddda737f9811e94bd040e5", size = 573516, upload-time = "2026-02-06T03:45:24.618Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6a/7e7fbeebc5cb51b6a5eba96b263a6298707bcbdc059f4b0b73e088bc3dea/mlx-0.30.6-cp313-cp313-macosx_26_0_arm64.whl", hash = "sha256:b5b6636f7c49a4d86d8ec82643b972f45a144a7a9f3a967b27b2e6e22cf71e6a", size = 573592, upload-time = "2026-02-06T03:45:25.928Z" }, + { url = "https://files.pythonhosted.org/packages/93/06/280f6f2ba80520a7109730425eda0d966658793aa0d02d8be8d351f75253/mlx-0.30.6-cp313-cp313-manylinux_2_35_aarch64.whl", hash = "sha256:67e6c9e30a9faeacc209917ef5523177cf9b086914b6b5d83ff886e4294b727d", size = 622011, upload-time = "2026-02-06T03:45:28.165Z" }, + { url = "https://files.pythonhosted.org/packages/fe/35/f872afbee9c079cc69924d9e9c46f5663adb7da58cba3511db082dd307c1/mlx-0.30.6-cp313-cp313-manylinux_2_35_x86_64.whl", hash = "sha256:47db8b16fcb6f6c5a47c0bdb24ed377b41237017ac93aa6cb6aa206c9bdf82e4", size = 663650, upload-time = "2026-02-06T03:45:30.315Z" }, + { url = "https://files.pythonhosted.org/packages/60/23/361dc7a5797634e4d7e9bdd6564c6b28f9b1246672632def2f91bf066b18/mlx-0.30.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:78804a89dcff4a838f7c2da72392fe87a523e95122a3c840e53df019122aad45", size = 575028, upload-time = "2026-02-06T03:45:31.549Z" }, + { url = "https://files.pythonhosted.org/packages/a8/69/1854484d414171586814dfbe8def95f75c4ea2c7341ba13ba8ee675f7c62/mlx-0.30.6-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:ec13584ab069665cc7ad34a05494d9291cd623aef6ae96be48875fc87cfc25d6", size = 575026, upload-time = "2026-02-06T03:45:33.072Z" }, + { url = "https://files.pythonhosted.org/packages/6b/b8/3adbc441924209a7e4c568308b2a0b54bd09aee6a68db5bae85304791e54/mlx-0.30.6-cp314-cp314-macosx_26_0_arm64.whl", hash = "sha256:b2c5e8a090a753ef99a1380a4d059c983083f36198864f6df9faaf1223d083df", size = 575041, upload-time = "2026-02-06T03:45:34.814Z" }, + { url = "https://files.pythonhosted.org/packages/3f/54/9d9e06804fb2088202a2cdf60458e00b221f71420bea285720b60f9e82b5/mlx-0.30.6-cp314-cp314-manylinux_2_35_aarch64.whl", hash = "sha256:9ceddede4af0de31d1f6b3099f70e5469d60cd7c546975dedbdbeab3519cab3f", size = 624002, upload-time = "2026-02-06T03:45:36Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/3140a15a50cb1f9267a6552171e1dfa577861de53e093124bc43707f2a0e/mlx-0.30.6-cp314-cp314-manylinux_2_35_x86_64.whl", hash = "sha256:4a6ffd2d16728cf95f63a1b555d7c2eaeea686a0e6b73228bd265411cb5d77a4", size = 663569, upload-time = "2026-02-06T03:45:37.242Z" }, ] [[package]] @@ -1256,12 +1278,12 @@ wheels = [ [[package]] name = "mlx-metal" -version = "0.30.3" +version = "0.30.6" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/63/4d8f6fefb507c028df4454dabfe8d8e0ad2961bb06510b6aca23d2d5b2be/mlx_metal-0.30.3-py3-none-macosx_14_0_arm64.whl", hash = "sha256:6276312b02353714c7c6515169569fe1c4bebe3229c8ecf1fdb375a13e78c966", size = 37716245, upload-time = "2026-01-14T01:16:34.838Z" }, - { url = "https://files.pythonhosted.org/packages/35/91/1d452e48a4bb4958844fd3bb28ae31b8de110549c009ebec5024ce27ebf3/mlx_metal-0.30.3-py3-none-macosx_15_0_arm64.whl", hash = "sha256:c096c0a3428f3f96a06220f97a36f9528b18bc05173f821eb05bc8458e723fa8", size = 37712125, upload-time = "2026-01-14T01:16:38.619Z" }, - { url = "https://files.pythonhosted.org/packages/fe/36/7a3cbca85542b5ca4faf871e35927f43aa0e3fc830ae5b699780fe723677/mlx_metal-0.30.3-py3-none-macosx_26_0_arm64.whl", hash = "sha256:69068533bd1ee8b0379ce5de57ed5fd313577a10ecab58e1332fd1ff7248a75e", size = 46488962, upload-time = "2026-01-14T05:52:04.523Z" }, + { url = "https://files.pythonhosted.org/packages/f3/85/44406b521f920248fad621334d4dc15e77660a494edf890e7cbee33bf38d/mlx_metal-0.30.6-py3-none-macosx_14_0_arm64.whl", hash = "sha256:ea6d0c973def9a5b4f652cc77036237db3f88c9d0af63701d76b5fddde99b820", size = 38437818, upload-time = "2026-02-06T03:44:56.19Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cb/10a516995f7d0c154b0d7e633c54b51e96977a86a355105b6474cfcbe0d0/mlx_metal-0.30.6-py3-none-macosx_15_0_arm64.whl", hash = "sha256:0f8cb94634d07e06a372d6ad9a090f38a18bab1ff19a140aede60eacf707bb94", size = 38433701, upload-time = "2026-02-06T03:44:59.678Z" }, + { url = "https://files.pythonhosted.org/packages/4c/7d/70cb272f7373c334709f210ed8420511fc9d64d05a7a646c0b3b94c29c04/mlx_metal-0.30.6-py3-none-macosx_26_0_arm64.whl", hash = "sha256:d761ae26304f2c4b454eeea7f612a56919d9e5e57dbb1dc0788f8e34aa6f41c2", size = 47718448, upload-time = "2026-02-06T03:45:03.133Z" }, ] [[package]] @@ -1638,109 +1660,109 @@ wheels = [ [[package]] name = "pillow" -version = "12.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/02/d52c733a2452ef1ffcc123b68e6606d07276b0e358db70eabad7e40042b7/pillow-12.1.0.tar.gz", hash = "sha256:5c5ae0a06e9ea030ab786b0251b32c7e4ce10e58d983c0d5c56029455180b5b9", size = 46977283, upload-time = "2026-01-02T09:13:29.892Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/41/f73d92b6b883a579e79600d391f2e21cb0df767b2714ecbd2952315dfeef/pillow-12.1.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:fb125d860738a09d363a88daa0f59c4533529a90e564785e20fe875b200b6dbd", size = 5304089, upload-time = "2026-01-02T09:10:24.953Z" }, - { url = "https://files.pythonhosted.org/packages/94/55/7aca2891560188656e4a91ed9adba305e914a4496800da6b5c0a15f09edf/pillow-12.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cad302dc10fac357d3467a74a9561c90609768a6f73a1923b0fd851b6486f8b0", size = 4657815, upload-time = "2026-01-02T09:10:27.063Z" }, - { url = "https://files.pythonhosted.org/packages/e9/d2/b28221abaa7b4c40b7dba948f0f6a708bd7342c4d47ce342f0ea39643974/pillow-12.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a40905599d8079e09f25027423aed94f2823adaf2868940de991e53a449e14a8", size = 6222593, upload-time = "2026-01-02T09:10:29.115Z" }, - { url = "https://files.pythonhosted.org/packages/71/b8/7a61fb234df6a9b0b479f69e66901209d89ff72a435b49933f9122f94cac/pillow-12.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:92a7fe4225365c5e3a8e598982269c6d6698d3e783b3b1ae979e7819f9cd55c1", size = 8027579, upload-time = "2026-01-02T09:10:31.182Z" }, - { url = "https://files.pythonhosted.org/packages/ea/51/55c751a57cc524a15a0e3db20e5cde517582359508d62305a627e77fd295/pillow-12.1.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f10c98f49227ed8383d28174ee95155a675c4ed7f85e2e573b04414f7e371bda", size = 6335760, upload-time = "2026-01-02T09:10:33.02Z" }, - { url = "https://files.pythonhosted.org/packages/dc/7c/60e3e6f5e5891a1a06b4c910f742ac862377a6fe842f7184df4a274ce7bf/pillow-12.1.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8637e29d13f478bc4f153d8daa9ffb16455f0a6cb287da1b432fdad2bfbd66c7", size = 7027127, upload-time = "2026-01-02T09:10:35.009Z" }, - { url = "https://files.pythonhosted.org/packages/06/37/49d47266ba50b00c27ba63a7c898f1bb41a29627ced8c09e25f19ebec0ff/pillow-12.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:21e686a21078b0f9cb8c8a961d99e6a4ddb88e0fc5ea6e130172ddddc2e5221a", size = 6449896, upload-time = "2026-01-02T09:10:36.793Z" }, - { url = "https://files.pythonhosted.org/packages/f9/e5/67fd87d2913902462cd9b79c6211c25bfe95fcf5783d06e1367d6d9a741f/pillow-12.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2415373395a831f53933c23ce051021e79c8cd7979822d8cc478547a3f4da8ef", size = 7151345, upload-time = "2026-01-02T09:10:39.064Z" }, - { url = "https://files.pythonhosted.org/packages/bd/15/f8c7abf82af68b29f50d77c227e7a1f87ce02fdc66ded9bf603bc3b41180/pillow-12.1.0-cp310-cp310-win32.whl", hash = "sha256:e75d3dba8fc1ddfec0cd752108f93b83b4f8d6ab40e524a95d35f016b9683b09", size = 6325568, upload-time = "2026-01-02T09:10:41.035Z" }, - { url = "https://files.pythonhosted.org/packages/d4/24/7d1c0e160b6b5ac2605ef7d8be537e28753c0db5363d035948073f5513d7/pillow-12.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:64efdf00c09e31efd754448a383ea241f55a994fd079866b92d2bbff598aad91", size = 7032367, upload-time = "2026-01-02T09:10:43.09Z" }, - { url = "https://files.pythonhosted.org/packages/f4/03/41c038f0d7a06099254c60f618d0ec7be11e79620fc23b8e85e5b31d9a44/pillow-12.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:f188028b5af6b8fb2e9a76ac0f841a575bd1bd396e46ef0840d9b88a48fdbcea", size = 2452345, upload-time = "2026-01-02T09:10:44.795Z" }, - { url = "https://files.pythonhosted.org/packages/43/c4/bf8328039de6cc22182c3ef007a2abfbbdab153661c0a9aa78af8d706391/pillow-12.1.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:a83e0850cb8f5ac975291ebfc4170ba481f41a28065277f7f735c202cd8e0af3", size = 5304057, upload-time = "2026-01-02T09:10:46.627Z" }, - { url = "https://files.pythonhosted.org/packages/43/06/7264c0597e676104cc22ca73ee48f752767cd4b1fe084662620b17e10120/pillow-12.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b6e53e82ec2db0717eabb276aa56cf4e500c9a7cec2c2e189b55c24f65a3e8c0", size = 4657811, upload-time = "2026-01-02T09:10:49.548Z" }, - { url = "https://files.pythonhosted.org/packages/72/64/f9189e44474610daf83da31145fa56710b627b5c4c0b9c235e34058f6b31/pillow-12.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:40a8e3b9e8773876d6e30daed22f016509e3987bab61b3b7fe309d7019a87451", size = 6232243, upload-time = "2026-01-02T09:10:51.62Z" }, - { url = "https://files.pythonhosted.org/packages/ef/30/0df458009be6a4caca4ca2c52975e6275c387d4e5c95544e34138b41dc86/pillow-12.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:800429ac32c9b72909c671aaf17ecd13110f823ddb7db4dfef412a5587c2c24e", size = 8037872, upload-time = "2026-01-02T09:10:53.446Z" }, - { url = "https://files.pythonhosted.org/packages/e4/86/95845d4eda4f4f9557e25381d70876aa213560243ac1a6d619c46caaedd9/pillow-12.1.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b022eaaf709541b391ee069f0022ee5b36c709df71986e3f7be312e46f42c84", size = 6345398, upload-time = "2026-01-02T09:10:55.426Z" }, - { url = "https://files.pythonhosted.org/packages/5c/1f/8e66ab9be3aaf1435bc03edd1ebdf58ffcd17f7349c1d970cafe87af27d9/pillow-12.1.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f345e7bc9d7f368887c712aa5054558bad44d2a301ddf9248599f4161abc7c0", size = 7034667, upload-time = "2026-01-02T09:10:57.11Z" }, - { url = "https://files.pythonhosted.org/packages/f9/f6/683b83cb9b1db1fb52b87951b1c0b99bdcfceaa75febf11406c19f82cb5e/pillow-12.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d70347c8a5b7ccd803ec0c85c8709f036e6348f1e6a5bf048ecd9c64d3550b8b", size = 6458743, upload-time = "2026-01-02T09:10:59.331Z" }, - { url = "https://files.pythonhosted.org/packages/9a/7d/de833d63622538c1d58ce5395e7c6cb7e7dce80decdd8bde4a484e095d9f/pillow-12.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1fcc52d86ce7a34fd17cb04e87cfdb164648a3662a6f20565910a99653d66c18", size = 7159342, upload-time = "2026-01-02T09:11:01.82Z" }, - { url = "https://files.pythonhosted.org/packages/8c/40/50d86571c9e5868c42b81fe7da0c76ca26373f3b95a8dd675425f4a92ec1/pillow-12.1.0-cp311-cp311-win32.whl", hash = "sha256:3ffaa2f0659e2f740473bcf03c702c39a8d4b2b7ffc629052028764324842c64", size = 6328655, upload-time = "2026-01-02T09:11:04.556Z" }, - { url = "https://files.pythonhosted.org/packages/6c/af/b1d7e301c4cd26cd45d4af884d9ee9b6fab893b0ad2450d4746d74a6968c/pillow-12.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:806f3987ffe10e867bab0ddad45df1148a2b98221798457fa097ad85d6e8bc75", size = 7031469, upload-time = "2026-01-02T09:11:06.538Z" }, - { url = "https://files.pythonhosted.org/packages/48/36/d5716586d887fb2a810a4a61518a327a1e21c8b7134c89283af272efe84b/pillow-12.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:9f5fefaca968e700ad1a4a9de98bf0869a94e397fe3524c4c9450c1445252304", size = 2452515, upload-time = "2026-01-02T09:11:08.226Z" }, - { url = "https://files.pythonhosted.org/packages/20/31/dc53fe21a2f2996e1b7d92bf671cdb157079385183ef7c1ae08b485db510/pillow-12.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a332ac4ccb84b6dde65dbace8431f3af08874bf9770719d32a635c4ef411b18b", size = 5262642, upload-time = "2026-01-02T09:11:10.138Z" }, - { url = "https://files.pythonhosted.org/packages/ab/c1/10e45ac9cc79419cedf5121b42dcca5a50ad2b601fa080f58c22fb27626e/pillow-12.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:907bfa8a9cb790748a9aa4513e37c88c59660da3bcfffbd24a7d9e6abf224551", size = 4657464, upload-time = "2026-01-02T09:11:12.319Z" }, - { url = "https://files.pythonhosted.org/packages/ad/26/7b82c0ab7ef40ebede7a97c72d473bda5950f609f8e0c77b04af574a0ddb/pillow-12.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:efdc140e7b63b8f739d09a99033aa430accce485ff78e6d311973a67b6bf3208", size = 6234878, upload-time = "2026-01-02T09:11:14.096Z" }, - { url = "https://files.pythonhosted.org/packages/76/25/27abc9792615b5e886ca9411ba6637b675f1b77af3104710ac7353fe5605/pillow-12.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bef9768cab184e7ae6e559c032e95ba8d07b3023c289f79a2bd36e8bf85605a5", size = 8044868, upload-time = "2026-01-02T09:11:15.903Z" }, - { url = "https://files.pythonhosted.org/packages/0a/ea/f200a4c36d836100e7bc738fc48cd963d3ba6372ebc8298a889e0cfc3359/pillow-12.1.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:742aea052cf5ab5034a53c3846165bc3ce88d7c38e954120db0ab867ca242661", size = 6349468, upload-time = "2026-01-02T09:11:17.631Z" }, - { url = "https://files.pythonhosted.org/packages/11/8f/48d0b77ab2200374c66d344459b8958c86693be99526450e7aee714e03e4/pillow-12.1.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6dfc2af5b082b635af6e08e0d1f9f1c4e04d17d4e2ca0ef96131e85eda6eb17", size = 7041518, upload-time = "2026-01-02T09:11:19.389Z" }, - { url = "https://files.pythonhosted.org/packages/1d/23/c281182eb986b5d31f0a76d2a2c8cd41722d6fb8ed07521e802f9bba52de/pillow-12.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:609e89d9f90b581c8d16358c9087df76024cf058fa693dd3e1e1620823f39670", size = 6462829, upload-time = "2026-01-02T09:11:21.28Z" }, - { url = "https://files.pythonhosted.org/packages/25/ef/7018273e0faac099d7b00982abdcc39142ae6f3bd9ceb06de09779c4a9d6/pillow-12.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:43b4899cfd091a9693a1278c4982f3e50f7fb7cff5153b05174b4afc9593b616", size = 7166756, upload-time = "2026-01-02T09:11:23.559Z" }, - { url = "https://files.pythonhosted.org/packages/8f/c8/993d4b7ab2e341fe02ceef9576afcf5830cdec640be2ac5bee1820d693d4/pillow-12.1.0-cp312-cp312-win32.whl", hash = "sha256:aa0c9cc0b82b14766a99fbe6084409972266e82f459821cd26997a488a7261a7", size = 6328770, upload-time = "2026-01-02T09:11:25.661Z" }, - { url = "https://files.pythonhosted.org/packages/a7/87/90b358775a3f02765d87655237229ba64a997b87efa8ccaca7dd3e36e7a7/pillow-12.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:d70534cea9e7966169ad29a903b99fc507e932069a881d0965a1a84bb57f6c6d", size = 7033406, upload-time = "2026-01-02T09:11:27.474Z" }, - { url = "https://files.pythonhosted.org/packages/5d/cf/881b457eccacac9e5b2ddd97d5071fb6d668307c57cbf4e3b5278e06e536/pillow-12.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:65b80c1ee7e14a87d6a068dd3b0aea268ffcabfe0498d38661b00c5b4b22e74c", size = 2452612, upload-time = "2026-01-02T09:11:29.309Z" }, - { url = "https://files.pythonhosted.org/packages/dd/c7/2530a4aa28248623e9d7f27316b42e27c32ec410f695929696f2e0e4a778/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7b5dd7cbae20285cdb597b10eb5a2c13aa9de6cde9bb64a3c1317427b1db1ae1", size = 4062543, upload-time = "2026-01-02T09:11:31.566Z" }, - { url = "https://files.pythonhosted.org/packages/8f/1f/40b8eae823dc1519b87d53c30ed9ef085506b05281d313031755c1705f73/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:29a4cef9cb672363926f0470afc516dbf7305a14d8c54f7abbb5c199cd8f8179", size = 4138373, upload-time = "2026-01-02T09:11:33.367Z" }, - { url = "https://files.pythonhosted.org/packages/d4/77/6fa60634cf06e52139fd0e89e5bbf055e8166c691c42fb162818b7fda31d/pillow-12.1.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:681088909d7e8fa9e31b9799aaa59ba5234c58e5e4f1951b4c4d1082a2e980e0", size = 3601241, upload-time = "2026-01-02T09:11:35.011Z" }, - { url = "https://files.pythonhosted.org/packages/4f/bf/28ab865de622e14b747f0cd7877510848252d950e43002e224fb1c9ababf/pillow-12.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:983976c2ab753166dc66d36af6e8ec15bb511e4a25856e2227e5f7e00a160587", size = 5262410, upload-time = "2026-01-02T09:11:36.682Z" }, - { url = "https://files.pythonhosted.org/packages/1c/34/583420a1b55e715937a85bd48c5c0991598247a1fd2eb5423188e765ea02/pillow-12.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:db44d5c160a90df2d24a24760bbd37607d53da0b34fb546c4c232af7192298ac", size = 4657312, upload-time = "2026-01-02T09:11:38.535Z" }, - { url = "https://files.pythonhosted.org/packages/1d/fd/f5a0896839762885b3376ff04878f86ab2b097c2f9a9cdccf4eda8ba8dc0/pillow-12.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6b7a9d1db5dad90e2991645874f708e87d9a3c370c243c2d7684d28f7e133e6b", size = 6232605, upload-time = "2026-01-02T09:11:40.602Z" }, - { url = "https://files.pythonhosted.org/packages/98/aa/938a09d127ac1e70e6ed467bd03834350b33ef646b31edb7452d5de43792/pillow-12.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6258f3260986990ba2fa8a874f8b6e808cf5abb51a94015ca3dc3c68aa4f30ea", size = 8041617, upload-time = "2026-01-02T09:11:42.721Z" }, - { url = "https://files.pythonhosted.org/packages/17/e8/538b24cb426ac0186e03f80f78bc8dc7246c667f58b540bdd57c71c9f79d/pillow-12.1.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e115c15e3bc727b1ca3e641a909f77f8ca72a64fff150f666fcc85e57701c26c", size = 6346509, upload-time = "2026-01-02T09:11:44.955Z" }, - { url = "https://files.pythonhosted.org/packages/01/9a/632e58ec89a32738cabfd9ec418f0e9898a2b4719afc581f07c04a05e3c9/pillow-12.1.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6741e6f3074a35e47c77b23a4e4f2d90db3ed905cb1c5e6e0d49bff2045632bc", size = 7038117, upload-time = "2026-01-02T09:11:46.736Z" }, - { url = "https://files.pythonhosted.org/packages/c7/a2/d40308cf86eada842ca1f3ffa45d0ca0df7e4ab33c83f81e73f5eaed136d/pillow-12.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:935b9d1aed48fcfb3f838caac506f38e29621b44ccc4f8a64d575cb1b2a88644", size = 6460151, upload-time = "2026-01-02T09:11:48.625Z" }, - { url = "https://files.pythonhosted.org/packages/f1/88/f5b058ad6453a085c5266660a1417bdad590199da1b32fb4efcff9d33b05/pillow-12.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5fee4c04aad8932da9f8f710af2c1a15a83582cfb884152a9caa79d4efcdbf9c", size = 7164534, upload-time = "2026-01-02T09:11:50.445Z" }, - { url = "https://files.pythonhosted.org/packages/19/ce/c17334caea1db789163b5d855a5735e47995b0b5dc8745e9a3605d5f24c0/pillow-12.1.0-cp313-cp313-win32.whl", hash = "sha256:a786bf667724d84aa29b5db1c61b7bfdde380202aaca12c3461afd6b71743171", size = 6332551, upload-time = "2026-01-02T09:11:52.234Z" }, - { url = "https://files.pythonhosted.org/packages/e5/07/74a9d941fa45c90a0d9465098fe1ec85de3e2afbdc15cc4766622d516056/pillow-12.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:461f9dfdafa394c59cd6d818bdfdbab4028b83b02caadaff0ffd433faf4c9a7a", size = 7040087, upload-time = "2026-01-02T09:11:54.822Z" }, - { url = "https://files.pythonhosted.org/packages/88/09/c99950c075a0e9053d8e880595926302575bc742b1b47fe1bbcc8d388d50/pillow-12.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:9212d6b86917a2300669511ed094a9406888362e085f2431a7da985a6b124f45", size = 2452470, upload-time = "2026-01-02T09:11:56.522Z" }, - { url = "https://files.pythonhosted.org/packages/b5/ba/970b7d85ba01f348dee4d65412476321d40ee04dcb51cd3735b9dc94eb58/pillow-12.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:00162e9ca6d22b7c3ee8e61faa3c3253cd19b6a37f126cad04f2f88b306f557d", size = 5264816, upload-time = "2026-01-02T09:11:58.227Z" }, - { url = "https://files.pythonhosted.org/packages/10/60/650f2fb55fdba7a510d836202aa52f0baac633e50ab1cf18415d332188fb/pillow-12.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7d6daa89a00b58c37cb1747ec9fb7ac3bc5ffd5949f5888657dfddde6d1312e0", size = 4660472, upload-time = "2026-01-02T09:12:00.798Z" }, - { url = "https://files.pythonhosted.org/packages/2b/c0/5273a99478956a099d533c4f46cbaa19fd69d606624f4334b85e50987a08/pillow-12.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2479c7f02f9d505682dc47df8c0ea1fc5e264c4d1629a5d63fe3e2334b89554", size = 6268974, upload-time = "2026-01-02T09:12:02.572Z" }, - { url = "https://files.pythonhosted.org/packages/b4/26/0bf714bc2e73d5267887d47931d53c4ceeceea6978148ed2ab2a4e6463c4/pillow-12.1.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f188d580bd870cda1e15183790d1cc2fa78f666e76077d103edf048eed9c356e", size = 8073070, upload-time = "2026-01-02T09:12:04.75Z" }, - { url = "https://files.pythonhosted.org/packages/43/cf/1ea826200de111a9d65724c54f927f3111dc5ae297f294b370a670c17786/pillow-12.1.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fde7ec5538ab5095cc02df38ee99b0443ff0e1c847a045554cf5f9af1f4aa82", size = 6380176, upload-time = "2026-01-02T09:12:06.626Z" }, - { url = "https://files.pythonhosted.org/packages/03/e0/7938dd2b2013373fd85d96e0f38d62b7a5a262af21ac274250c7ca7847c9/pillow-12.1.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ed07dca4a8464bada6139ab38f5382f83e5f111698caf3191cb8dbf27d908b4", size = 7067061, upload-time = "2026-01-02T09:12:08.624Z" }, - { url = "https://files.pythonhosted.org/packages/86/ad/a2aa97d37272a929a98437a8c0ac37b3cf012f4f8721e1bd5154699b2518/pillow-12.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f45bd71d1fa5e5749587613037b172e0b3b23159d1c00ef2fc920da6f470e6f0", size = 6491824, upload-time = "2026-01-02T09:12:10.488Z" }, - { url = "https://files.pythonhosted.org/packages/a4/44/80e46611b288d51b115826f136fb3465653c28f491068a72d3da49b54cd4/pillow-12.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:277518bf4fe74aa91489e1b20577473b19ee70fb97c374aa50830b279f25841b", size = 7190911, upload-time = "2026-01-02T09:12:12.772Z" }, - { url = "https://files.pythonhosted.org/packages/86/77/eacc62356b4cf81abe99ff9dbc7402750044aed02cfd6a503f7c6fc11f3e/pillow-12.1.0-cp313-cp313t-win32.whl", hash = "sha256:7315f9137087c4e0ee73a761b163fc9aa3b19f5f606a7fc08d83fd3e4379af65", size = 6336445, upload-time = "2026-01-02T09:12:14.775Z" }, - { url = "https://files.pythonhosted.org/packages/e7/3c/57d81d0b74d218706dafccb87a87ea44262c43eef98eb3b164fd000e0491/pillow-12.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0ddedfaa8b5f0b4ffbc2fa87b556dc59f6bb4ecb14a53b33f9189713ae8053c0", size = 7045354, upload-time = "2026-01-02T09:12:16.599Z" }, - { url = "https://files.pythonhosted.org/packages/ac/82/8b9b97bba2e3576a340f93b044a3a3a09841170ab4c1eb0d5c93469fd32f/pillow-12.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:80941e6d573197a0c28f394753de529bb436b1ca990ed6e765cf42426abc39f8", size = 2454547, upload-time = "2026-01-02T09:12:18.704Z" }, - { url = "https://files.pythonhosted.org/packages/8c/87/bdf971d8bbcf80a348cc3bacfcb239f5882100fe80534b0ce67a784181d8/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:5cb7bc1966d031aec37ddb9dcf15c2da5b2e9f7cc3ca7c54473a20a927e1eb91", size = 4062533, upload-time = "2026-01-02T09:12:20.791Z" }, - { url = "https://files.pythonhosted.org/packages/ff/4f/5eb37a681c68d605eb7034c004875c81f86ec9ef51f5be4a63eadd58859a/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:97e9993d5ed946aba26baf9c1e8cf18adbab584b99f452ee72f7ee8acb882796", size = 4138546, upload-time = "2026-01-02T09:12:23.664Z" }, - { url = "https://files.pythonhosted.org/packages/11/6d/19a95acb2edbace40dcd582d077b991646b7083c41b98da4ed7555b59733/pillow-12.1.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:414b9a78e14ffeb98128863314e62c3f24b8a86081066625700b7985b3f529bd", size = 3601163, upload-time = "2026-01-02T09:12:26.338Z" }, - { url = "https://files.pythonhosted.org/packages/fc/36/2b8138e51cb42e4cc39c3297713455548be855a50558c3ac2beebdc251dd/pillow-12.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e6bdb408f7c9dd2a5ff2b14a3b0bb6d4deb29fb9961e6eb3ae2031ae9a5cec13", size = 5266086, upload-time = "2026-01-02T09:12:28.782Z" }, - { url = "https://files.pythonhosted.org/packages/53/4b/649056e4d22e1caa90816bf99cef0884aed607ed38075bd75f091a607a38/pillow-12.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3413c2ae377550f5487991d444428f1a8ae92784aac79caa8b1e3b89b175f77e", size = 4657344, upload-time = "2026-01-02T09:12:31.117Z" }, - { url = "https://files.pythonhosted.org/packages/6c/6b/c5742cea0f1ade0cd61485dc3d81f05261fc2276f537fbdc00802de56779/pillow-12.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e5dcbe95016e88437ecf33544ba5db21ef1b8dd6e1b434a2cb2a3d605299e643", size = 6232114, upload-time = "2026-01-02T09:12:32.936Z" }, - { url = "https://files.pythonhosted.org/packages/bf/8f/9f521268ce22d63991601aafd3d48d5ff7280a246a1ef62d626d67b44064/pillow-12.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d0a7735df32ccbcc98b98a1ac785cc4b19b580be1bdf0aeb5c03223220ea09d5", size = 8042708, upload-time = "2026-01-02T09:12:34.78Z" }, - { url = "https://files.pythonhosted.org/packages/1a/eb/257f38542893f021502a1bbe0c2e883c90b5cff26cc33b1584a841a06d30/pillow-12.1.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c27407a2d1b96774cbc4a7594129cc027339fd800cd081e44497722ea1179de", size = 6347762, upload-time = "2026-01-02T09:12:36.748Z" }, - { url = "https://files.pythonhosted.org/packages/c4/5a/8ba375025701c09b309e8d5163c5a4ce0102fa86bbf8800eb0d7ac87bc51/pillow-12.1.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15c794d74303828eaa957ff8070846d0efe8c630901a1c753fdc63850e19ecd9", size = 7039265, upload-time = "2026-01-02T09:12:39.082Z" }, - { url = "https://files.pythonhosted.org/packages/cf/dc/cf5e4cdb3db533f539e88a7bbf9f190c64ab8a08a9bc7a4ccf55067872e4/pillow-12.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c990547452ee2800d8506c4150280757f88532f3de2a58e3022e9b179107862a", size = 6462341, upload-time = "2026-01-02T09:12:40.946Z" }, - { url = "https://files.pythonhosted.org/packages/d0/47/0291a25ac9550677e22eda48510cfc4fa4b2ef0396448b7fbdc0a6946309/pillow-12.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b63e13dd27da389ed9475b3d28510f0f954bca0041e8e551b2a4eb1eab56a39a", size = 7165395, upload-time = "2026-01-02T09:12:42.706Z" }, - { url = "https://files.pythonhosted.org/packages/4f/4c/e005a59393ec4d9416be06e6b45820403bb946a778e39ecec62f5b2b991e/pillow-12.1.0-cp314-cp314-win32.whl", hash = "sha256:1a949604f73eb07a8adab38c4fe50791f9919344398bdc8ac6b307f755fc7030", size = 6431413, upload-time = "2026-01-02T09:12:44.944Z" }, - { url = "https://files.pythonhosted.org/packages/1c/af/f23697f587ac5f9095d67e31b81c95c0249cd461a9798a061ed6709b09b5/pillow-12.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f9f6a650743f0ddee5593ac9e954ba1bdbc5e150bc066586d4f26127853ab94", size = 7176779, upload-time = "2026-01-02T09:12:46.727Z" }, - { url = "https://files.pythonhosted.org/packages/b3/36/6a51abf8599232f3e9afbd16d52829376a68909fe14efe29084445db4b73/pillow-12.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:808b99604f7873c800c4840f55ff389936ef1948e4e87645eaf3fccbc8477ac4", size = 2543105, upload-time = "2026-01-02T09:12:49.243Z" }, - { url = "https://files.pythonhosted.org/packages/82/54/2e1dd20c8749ff225080d6ba465a0cab4387f5db0d1c5fb1439e2d99923f/pillow-12.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc11908616c8a283cf7d664f77411a5ed2a02009b0097ff8abbba5e79128ccf2", size = 5268571, upload-time = "2026-01-02T09:12:51.11Z" }, - { url = "https://files.pythonhosted.org/packages/57/61/571163a5ef86ec0cf30d265ac2a70ae6fc9e28413d1dc94fa37fae6bda89/pillow-12.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:896866d2d436563fa2a43a9d72f417874f16b5545955c54a64941e87c1376c61", size = 4660426, upload-time = "2026-01-02T09:12:52.865Z" }, - { url = "https://files.pythonhosted.org/packages/5e/e1/53ee5163f794aef1bf84243f755ee6897a92c708505350dd1923f4afec48/pillow-12.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8e178e3e99d3c0ea8fc64b88447f7cac8ccf058af422a6cedc690d0eadd98c51", size = 6269908, upload-time = "2026-01-02T09:12:54.884Z" }, - { url = "https://files.pythonhosted.org/packages/bc/0b/b4b4106ff0ee1afa1dc599fde6ab230417f800279745124f6c50bcffed8e/pillow-12.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:079af2fb0c599c2ec144ba2c02766d1b55498e373b3ac64687e43849fbbef5bc", size = 8074733, upload-time = "2026-01-02T09:12:56.802Z" }, - { url = "https://files.pythonhosted.org/packages/19/9f/80b411cbac4a732439e629a26ad3ef11907a8c7fc5377b7602f04f6fe4e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdec5e43377761c5dbca620efb69a77f6855c5a379e32ac5b158f54c84212b14", size = 6381431, upload-time = "2026-01-02T09:12:58.823Z" }, - { url = "https://files.pythonhosted.org/packages/8f/b7/d65c45db463b66ecb6abc17c6ba6917a911202a07662247e1355ce1789e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:565c986f4b45c020f5421a4cea13ef294dde9509a8577f29b2fc5edc7587fff8", size = 7068529, upload-time = "2026-01-02T09:13:00.885Z" }, - { url = "https://files.pythonhosted.org/packages/50/96/dfd4cd726b4a45ae6e3c669fc9e49deb2241312605d33aba50499e9d9bd1/pillow-12.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43aca0a55ce1eefc0aefa6253661cb54571857b1a7b2964bd8a1e3ef4b729924", size = 6492981, upload-time = "2026-01-02T09:13:03.314Z" }, - { url = "https://files.pythonhosted.org/packages/4d/1c/b5dc52cf713ae46033359c5ca920444f18a6359ce1020dd3e9c553ea5bc6/pillow-12.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0deedf2ea233722476b3a81e8cdfbad786f7adbed5d848469fa59fe52396e4ef", size = 7191878, upload-time = "2026-01-02T09:13:05.276Z" }, - { url = "https://files.pythonhosted.org/packages/53/26/c4188248bd5edaf543864fe4834aebe9c9cb4968b6f573ce014cc42d0720/pillow-12.1.0-cp314-cp314t-win32.whl", hash = "sha256:b17fbdbe01c196e7e159aacb889e091f28e61020a8abeac07b68079b6e626988", size = 6438703, upload-time = "2026-01-02T09:13:07.491Z" }, - { url = "https://files.pythonhosted.org/packages/b8/0e/69ed296de8ea05cb03ee139cee600f424ca166e632567b2d66727f08c7ed/pillow-12.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27b9baecb428899db6c0de572d6d305cfaf38ca1596b5c0542a5182e3e74e8c6", size = 7182927, upload-time = "2026-01-02T09:13:09.841Z" }, - { url = "https://files.pythonhosted.org/packages/fc/f5/68334c015eed9b5cff77814258717dec591ded209ab5b6fb70e2ae873d1d/pillow-12.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f61333d817698bdcdd0f9d7793e365ac3d2a21c1f1eb02b32ad6aefb8d8ea831", size = 2545104, upload-time = "2026-01-02T09:13:12.068Z" }, - { url = "https://files.pythonhosted.org/packages/8b/bc/224b1d98cffd7164b14707c91aac83c07b047fbd8f58eba4066a3e53746a/pillow-12.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ca94b6aac0d7af2a10ba08c0f888b3d5114439b6b3ef39968378723622fed377", size = 5228605, upload-time = "2026-01-02T09:13:14.084Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ca/49ca7769c4550107de049ed85208240ba0f330b3f2e316f24534795702ce/pillow-12.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:351889afef0f485b84078ea40fe33727a0492b9af3904661b0abbafee0355b72", size = 4622245, upload-time = "2026-01-02T09:13:15.964Z" }, - { url = "https://files.pythonhosted.org/packages/73/48/fac807ce82e5955bcc2718642b94b1bd22a82a6d452aea31cbb678cddf12/pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb0984b30e973f7e2884362b7d23d0a348c7143ee559f38ef3eaab640144204c", size = 5247593, upload-time = "2026-01-02T09:13:17.913Z" }, - { url = "https://files.pythonhosted.org/packages/d2/95/3e0742fe358c4664aed4fd05d5f5373dcdad0b27af52aa0972568541e3f4/pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:84cabc7095dd535ca934d57e9ce2a72ffd216e435a84acb06b2277b1de2689bd", size = 6989008, upload-time = "2026-01-02T09:13:20.083Z" }, - { url = "https://files.pythonhosted.org/packages/5a/74/fe2ac378e4e202e56d50540d92e1ef4ff34ed687f3c60f6a121bcf99437e/pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53d8b764726d3af1a138dd353116f774e3862ec7e3794e0c8781e30db0f35dfc", size = 5313824, upload-time = "2026-01-02T09:13:22.405Z" }, - { url = "https://files.pythonhosted.org/packages/f3/77/2a60dee1adee4e2655ac328dd05c02a955c1cd683b9f1b82ec3feb44727c/pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5da841d81b1a05ef940a8567da92decaa15bc4d7dedb540a8c219ad83d91808a", size = 5963278, upload-time = "2026-01-02T09:13:24.706Z" }, - { url = "https://files.pythonhosted.org/packages/2d/71/64e9b1c7f04ae0027f788a248e6297d7fcc29571371fe7d45495a78172c0/pillow-12.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:75af0b4c229ac519b155028fa1be632d812a519abba9b46b20e50c6caa184f19", size = 7029809, upload-time = "2026-01-02T09:13:26.541Z" }, +version = "12.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/30/5bd3d794762481f8c8ae9c80e7b76ecea73b916959eb587521358ef0b2f9/pillow-12.1.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1f1625b72740fdda5d77b4def688eb8fd6490975d06b909fd19f13f391e077e0", size = 5304099, upload-time = "2026-02-11T04:20:06.13Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c1/aab9e8f3eeb4490180e357955e15c2ef74b31f64790ff356c06fb6cf6d84/pillow-12.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:178aa072084bd88ec759052feca8e56cbb14a60b39322b99a049e58090479713", size = 4657880, upload-time = "2026-02-11T04:20:09.291Z" }, + { url = "https://files.pythonhosted.org/packages/f1/0a/9879e30d56815ad529d3985aeff5af4964202425c27261a6ada10f7cbf53/pillow-12.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b66e95d05ba806247aaa1561f080abc7975daf715c30780ff92a20e4ec546e1b", size = 6222587, upload-time = "2026-02-11T04:20:10.82Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5f/a1b72ff7139e4f89014e8d451442c74a774d5c43cd938fb0a9f878576b37/pillow-12.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:89c7e895002bbe49cdc5426150377cbbc04767d7547ed145473f496dfa40408b", size = 8027678, upload-time = "2026-02-11T04:20:12.455Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c2/c7cb187dac79a3d22c3ebeae727abee01e077c8c7d930791dc592f335153/pillow-12.1.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a5cbdcddad0af3da87cb16b60d23648bc3b51967eb07223e9fed77a82b457c4", size = 6335777, upload-time = "2026-02-11T04:20:14.441Z" }, + { url = "https://files.pythonhosted.org/packages/0c/7b/f9b09a7804ec7336effb96c26d37c29d27225783dc1501b7d62dcef6ae25/pillow-12.1.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f51079765661884a486727f0729d29054242f74b46186026582b4e4769918e4", size = 7027140, upload-time = "2026-02-11T04:20:16.387Z" }, + { url = "https://files.pythonhosted.org/packages/98/b2/2fa3c391550bd421b10849d1a2144c44abcd966daadd2f7c12e19ea988c4/pillow-12.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:99c1506ea77c11531d75e3a412832a13a71c7ebc8192ab9e4b2e355555920e3e", size = 6449855, upload-time = "2026-02-11T04:20:18.554Z" }, + { url = "https://files.pythonhosted.org/packages/96/ff/9caf4b5b950c669263c39e96c78c0d74a342c71c4f43fd031bb5cb7ceac9/pillow-12.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:36341d06738a9f66c8287cf8b876d24b18db9bd8740fa0672c74e259ad408cff", size = 7151329, upload-time = "2026-02-11T04:20:20.646Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f8/4b24841f582704da675ca535935bccb32b00a6da1226820845fac4a71136/pillow-12.1.1-cp310-cp310-win32.whl", hash = "sha256:6c52f062424c523d6c4db85518774cc3d50f5539dd6eed32b8f6229b26f24d40", size = 6325574, upload-time = "2026-02-11T04:20:22.43Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f9/9f6b01c0881d7036063aa6612ef04c0e2cad96be21325a1e92d0203f8e91/pillow-12.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:c6008de247150668a705a6338156efb92334113421ceecf7438a12c9a12dab23", size = 7032347, upload-time = "2026-02-11T04:20:23.932Z" }, + { url = "https://files.pythonhosted.org/packages/79/13/c7922edded3dcdaf10c59297540b72785620abc0538872c819915746757d/pillow-12.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:1a9b0ee305220b392e1124a764ee4265bd063e54a751a6b62eff69992f457fa9", size = 2453457, upload-time = "2026-02-11T04:20:25.392Z" }, + { url = "https://files.pythonhosted.org/packages/2b/46/5da1ec4a5171ee7bf1a0efa064aba70ba3d6e0788ce3f5acd1375d23c8c0/pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32", size = 5304084, upload-time = "2026-02-11T04:20:27.501Z" }, + { url = "https://files.pythonhosted.org/packages/78/93/a29e9bc02d1cf557a834da780ceccd54e02421627200696fcf805ebdc3fb/pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38", size = 4657866, upload-time = "2026-02-11T04:20:29.827Z" }, + { url = "https://files.pythonhosted.org/packages/13/84/583a4558d492a179d31e4aae32eadce94b9acf49c0337c4ce0b70e0a01f2/pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5", size = 6232148, upload-time = "2026-02-11T04:20:31.329Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e2/53c43334bbbb2d3b938978532fbda8e62bb6e0b23a26ce8592f36bcc4987/pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090", size = 8038007, upload-time = "2026-02-11T04:20:34.225Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/3d0e79c8a9d58150dd98e199d7c1c56861027f3829a3a60b3c2784190180/pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af", size = 6345418, upload-time = "2026-02-11T04:20:35.858Z" }, + { url = "https://files.pythonhosted.org/packages/a2/c8/46dfeac5825e600579157eea177be43e2f7ff4a99da9d0d0a49533509ac5/pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b", size = 7034590, upload-time = "2026-02-11T04:20:37.91Z" }, + { url = "https://files.pythonhosted.org/packages/af/bf/e6f65d3db8a8bbfeaf9e13cc0417813f6319863a73de934f14b2229ada18/pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5", size = 6458655, upload-time = "2026-02-11T04:20:39.496Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c2/66091f3f34a25894ca129362e510b956ef26f8fb67a0e6417bc5744e56f1/pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d", size = 7159286, upload-time = "2026-02-11T04:20:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5a/24bc8eb526a22f957d0cec6243146744966d40857e3d8deb68f7902ca6c1/pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c", size = 6328663, upload-time = "2026-02-11T04:20:43.184Z" }, + { url = "https://files.pythonhosted.org/packages/31/03/bef822e4f2d8f9d7448c133d0a18185d3cce3e70472774fffefe8b0ed562/pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563", size = 7031448, upload-time = "2026-02-11T04:20:44.696Z" }, + { url = "https://files.pythonhosted.org/packages/49/70/f76296f53610bd17b2e7d31728b8b7825e3ac3b5b3688b51f52eab7c0818/pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80", size = 2453651, upload-time = "2026-02-11T04:20:46.243Z" }, + { url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" }, + { url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" }, + { url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" }, + { url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" }, + { url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" }, + { url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" }, + { url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" }, + { url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" }, + { url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" }, + { url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" }, + { url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" }, + { url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" }, + { url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" }, + { url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" }, + { url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" }, + { url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" }, + { url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" }, + { url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" }, + { url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" }, + { url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" }, + { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, + { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, + { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, + { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, + { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, + { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, + { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, + { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, + { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, + { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, + { url = "https://files.pythonhosted.org/packages/56/11/5d43209aa4cb58e0cc80127956ff1796a68b928e6324bbf06ef4db34367b/pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f", size = 5228606, upload-time = "2026-02-11T04:22:52.106Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d5/3b005b4e4fda6698b371fa6c21b097d4707585d7db99e98d9b0b87ac612a/pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9", size = 4622321, upload-time = "2026-02-11T04:22:53.827Z" }, + { url = "https://files.pythonhosted.org/packages/df/36/ed3ea2d594356fd8037e5a01f6156c74bc8d92dbb0fa60746cc96cabb6e8/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e", size = 5247579, upload-time = "2026-02-11T04:22:56.094Z" }, + { url = "https://files.pythonhosted.org/packages/54/9a/9cc3e029683cf6d20ae5085da0dafc63148e3252c2f13328e553aaa13cfb/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9", size = 6989094, upload-time = "2026-02-11T04:22:58.288Z" }, + { url = "https://files.pythonhosted.org/packages/00/98/fc53ab36da80b88df0967896b6c4b4cd948a0dc5aa40a754266aa3ae48b3/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3", size = 5313850, upload-time = "2026-02-11T04:23:00.554Z" }, + { url = "https://files.pythonhosted.org/packages/30/02/00fa585abfd9fe9d73e5f6e554dc36cc2b842898cbfc46d70353dae227f8/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735", size = 5963343, upload-time = "2026-02-11T04:23:02.934Z" }, + { url = "https://files.pythonhosted.org/packages/f2/26/c56ce33ca856e358d27fda9676c055395abddb82c35ac0f593877ed4562e/pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", size = 7029880, upload-time = "2026-02-11T04:23:04.783Z" }, ] [[package]] name = "platformdirs" -version = "4.5.1" +version = "4.9.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/d5/763666321efaded11112de8b7a7f2273dd8d1e205168e73c334e54b0ab9a/platformdirs-4.9.1.tar.gz", hash = "sha256:f310f16e89c4e29117805d8328f7c10876eeff36c94eac879532812110f7d39f", size = 28392, upload-time = "2026-02-14T21:02:44.973Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, + { url = "https://files.pythonhosted.org/packages/70/77/e8c95e95f1d4cdd88c90a96e31980df7e709e51059fac150046ad67fac63/platformdirs-4.9.1-py3-none-any.whl", hash = "sha256:61d8b967d34791c162d30d60737369cbbd77debad5b981c4bfda1842e71e0d66", size = 21307, upload-time = "2026-02-14T21:02:43.492Z" }, ] [[package]] @@ -1754,16 +1776,16 @@ wheels = [ [[package]] name = "pooch" -version = "1.8.2" +version = "1.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, { name = "platformdirs" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c6/77/b3d3e00c696c16cf99af81ef7b1f5fe73bd2a307abca41bd7605429fe6e5/pooch-1.8.2.tar.gz", hash = "sha256:76561f0de68a01da4df6af38e9955c4c9d1a5c90da73f7e40276a5728ec83d10", size = 59353, upload-time = "2024-06-06T16:53:46.224Z" } +sdist = { url = "https://files.pythonhosted.org/packages/83/43/85ef45e8b36c6a48546af7b266592dc32d7f67837a6514d111bced6d7d75/pooch-1.9.0.tar.gz", hash = "sha256:de46729579b9857ffd3e741987a2f6d5e0e03219892c167c6578c0091fb511ed", size = 61788, upload-time = "2026-01-30T19:15:09.649Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/87/77cc11c7a9ea9fd05503def69e3d18605852cd0d4b0d3b8f15bbeb3ef1d1/pooch-1.8.2-py3-none-any.whl", hash = "sha256:3529a57096f7198778a5ceefd5ac3ef0e4d06a6ddaf9fc2d609b806f25302c47", size = 64574, upload-time = "2024-06-06T16:53:44.343Z" }, + { url = "https://files.pythonhosted.org/packages/2a/2d/d4bf65e47cea8ff2c794a600c4fd1273a7902f268757c531e0ee9f18aa58/pooch-1.9.0-py3-none-any.whl", hash = "sha256:f265597baa9f760d25ceb29d0beb8186c243d6607b0f60b83ecf14078dbc703b", size = 67175, upload-time = "2026-01-30T19:15:08.36Z" }, ] [[package]] @@ -2217,15 +2239,15 @@ wheels = [ [[package]] name = "rdflib" -version = "7.5.0" +version = "7.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "isodate", marker = "python_full_version < '3.11'" }, { name = "pyparsing" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ec/1b/4cd9a29841951371304828d13282e27a5f25993702c7c87dcb7e0604bd25/rdflib-7.5.0.tar.gz", hash = "sha256:663083443908b1830e567350d72e74d9948b310f827966358d76eebdc92bf592", size = 4903859, upload-time = "2025-11-28T05:51:54.562Z" } +sdist = { url = "https://files.pythonhosted.org/packages/98/f5/18bb77b7af9526add0c727a3b2048959847dc5fb030913e2918bf384fec3/rdflib-7.6.0.tar.gz", hash = "sha256:6c831288d5e4a5a7ece85d0ccde9877d512a3d0f02d7c06455d00d6d0ea379df", size = 4943826, upload-time = "2026-02-13T07:15:55.938Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/20/35d2baebacf357b562bd081936b66cd845775442973cb033a377fd639a84/rdflib-7.5.0-py3-none-any.whl", hash = "sha256:b011dfc40d0fc8a44252e906dcd8fc806a7859bc231be190c37e9568a31ac572", size = 587215, upload-time = "2025-11-28T05:51:38.178Z" }, + { url = "https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl", hash = "sha256:30c0a3ebf4c0e09215f066be7246794b6492e054e782d7ac2a34c9f70a15e0dd", size = 615416, upload-time = "2026-02-13T07:15:46.487Z" }, ] [[package]] @@ -2387,6 +2409,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/e5/63ca2c4edf4e00657584608bee1001302bbf8c5f569340b78304f2f446cb/rfc3986-1.5.0-py2.py3-none-any.whl", hash = "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97", size = 31976, upload-time = "2021-05-07T23:29:25.611Z" }, ] +[[package]] +name = "rich" +version = "14.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/99/a4cab2acbb884f80e558b0771e97e21e939c5dfb460f488d19df485e8298/rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8", size = 230143, upload-time = "2026-02-01T16:20:47.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/45/615f5babd880b4bd7d405cc0dc348234c5ffb6ed1ea33e152ede08b2072d/rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69", size = 309963, upload-time = "2026-02-01T16:20:46.078Z" }, +] + [[package]] name = "rpds-py" version = "0.30.0" @@ -2849,11 +2884,11 @@ wheels = [ [[package]] name = "setuptools" -version = "80.10.1" +version = "82.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/86/ff/f75651350db3cf2ef767371307eb163f3cc1ac03e16fdf3ac347607f7edb/setuptools-80.10.1.tar.gz", hash = "sha256:bf2e513eb8144c3298a3bd28ab1a5edb739131ec5c22e045ff93cd7f5319703a", size = 1229650, upload-time = "2026-01-21T09:42:03.061Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/f3/748f4d6f65d1756b9ae577f329c951cda23fb900e4de9f70900ced962085/setuptools-82.0.0.tar.gz", hash = "sha256:22e0a2d69474c6ae4feb01951cb69d515ed23728cf96d05513d36e42b62b37cb", size = 1144893, upload-time = "2026-02-08T15:08:40.206Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/76/f963c61683a39084aa575f98089253e1e852a4417cb8a3a8a422923a5246/setuptools-80.10.1-py3-none-any.whl", hash = "sha256:fc30c51cbcb8199a219c12cc9c281b5925a4978d212f84229c909636d9f6984e", size = 1099859, upload-time = "2026-01-21T09:42:00.688Z" }, + { url = "https://files.pythonhosted.org/packages/e1/c6/76dc613121b793286a3f91621d7b75a2b493e0390ddca50f11993eadf192/setuptools-82.0.0-py3-none-any.whl", hash = "sha256:70b18734b607bd1da571d097d236cfcfacaf01de45717d59e6e04b96877532e0", size = 1003468, upload-time = "2026-02-08T15:08:38.723Z" }, ] [[package]] @@ -3128,15 +3163,15 @@ wheels = [ [[package]] name = "starlette" -version = "0.50.0" +version = "0.52.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, + { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, ] [[package]] @@ -3366,14 +3401,14 @@ wheels = [ [[package]] name = "tqdm" -version = "4.67.1" +version = "4.67.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" } +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, ] [[package]] @@ -3400,16 +3435,30 @@ wheels = [ ] [[package]] -name = "typer-slim" -version = "0.21.1" +name = "typer" +version = "0.23.1" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "annotated-doc" }, { name = "click" }, - { name = "typing-extensions" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/07/b822e1b307d40e263e8253d2384cf98c51aa2368cc7ba9a07e523a1d964b/typer-0.23.1.tar.gz", hash = "sha256:2070374e4d31c83e7b61362fd859aa683576432fd5b026b060ad6b4cd3b86134", size = 120047, upload-time = "2026-02-13T10:04:30.984Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/91/9b286ab899c008c2cb05e8be99814807e7fbbd33f0c0c960470826e5ac82/typer-0.23.1-py3-none-any.whl", hash = "sha256:3291ad0d3c701cbf522012faccfbb29352ff16ad262db2139e6b01f15781f14e", size = 56813, upload-time = "2026-02-13T10:04:32.008Z" }, +] + +[[package]] +name = "typer-slim" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/17/d4/064570dec6358aa9049d4708e4a10407d74c99258f8b2136bb8702303f1a/typer_slim-0.21.1.tar.gz", hash = "sha256:73495dd08c2d0940d611c5a8c04e91c2a0a98600cbd4ee19192255a233b6dbfd", size = 110478, upload-time = "2026-01-06T11:21:11.176Z" } +sdist = { url = "https://files.pythonhosted.org/packages/da/22/b9c47b8655937b6877d40791b937931702ba9c5f9d28753199266aa96f50/typer_slim-0.23.1.tar.gz", hash = "sha256:dfe92a6317030ee2380f65bf92e540d7c77fefcc689e10d585b4925b45b5e06a", size = 4762, upload-time = "2026-02-13T10:04:26.416Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/0a/4aca634faf693e33004796b6cee0ae2e1dba375a800c16ab8d3eff4bb800/typer_slim-0.21.1-py3-none-any.whl", hash = "sha256:6e6c31047f171ac93cc5a973c9e617dbc5ab2bddc4d0a3135dc161b4e2020e0d", size = 47444, upload-time = "2026-01-06T11:21:12.441Z" }, + { url = "https://files.pythonhosted.org/packages/ad/8a/5764b851659345f34787f1b6eb30b9d308bbd6c294825cbe38b6b869c97a/typer_slim-0.23.1-py3-none-any.whl", hash = "sha256:8146d5df1eb89f628191c4c604c8464fa841885d0733c58e6e700ff0228adac5", size = 3397, upload-time = "2026-02-13T10:04:27.132Z" }, ] [[package]] @@ -3520,93 +3569,72 @@ sdist = { url = "https://files.pythonhosted.org/packages/89/34/e2de2d97f3288512b [[package]] name = "wrapt" -version = "2.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/49/2a/6de8a50cb435b7f42c46126cf1a54b2aab81784e74c8595c8e025e8f36d3/wrapt-2.0.1.tar.gz", hash = "sha256:9c9c635e78497cacb81e84f8b11b23e0aacac7a136e73b8e5b2109a1d9fc468f", size = 82040, upload-time = "2025-11-07T00:45:33.312Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/61/0d/12d8c803ed2ce4e5e7d5b9f5f602721f9dfef82c95959f3ce97fa584bb5c/wrapt-2.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:64b103acdaa53b7caf409e8d45d39a8442fe6dcfec6ba3f3d141e0cc2b5b4dbd", size = 77481, upload-time = "2025-11-07T00:43:11.103Z" }, - { url = "https://files.pythonhosted.org/packages/05/3e/4364ebe221ebf2a44d9fc8695a19324692f7dd2795e64bd59090856ebf12/wrapt-2.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:91bcc576260a274b169c3098e9a3519fb01f2989f6d3d386ef9cbf8653de1374", size = 60692, upload-time = "2025-11-07T00:43:13.697Z" }, - { url = "https://files.pythonhosted.org/packages/1f/ff/ae2a210022b521f86a8ddcdd6058d137c051003812b0388a5e9a03d3fe10/wrapt-2.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ab594f346517010050126fcd822697b25a7031d815bb4fbc238ccbe568216489", size = 61574, upload-time = "2025-11-07T00:43:14.967Z" }, - { url = "https://files.pythonhosted.org/packages/c6/93/5cf92edd99617095592af919cb81d4bff61c5dbbb70d3c92099425a8ec34/wrapt-2.0.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:36982b26f190f4d737f04a492a68accbfc6fa042c3f42326fdfbb6c5b7a20a31", size = 113688, upload-time = "2025-11-07T00:43:18.275Z" }, - { url = "https://files.pythonhosted.org/packages/a0/0a/e38fc0cee1f146c9fb266d8ef96ca39fb14a9eef165383004019aa53f88a/wrapt-2.0.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23097ed8bc4c93b7bf36fa2113c6c733c976316ce0ee2c816f64ca06102034ef", size = 115698, upload-time = "2025-11-07T00:43:19.407Z" }, - { url = "https://files.pythonhosted.org/packages/b0/85/bef44ea018b3925fb0bcbe9112715f665e4d5309bd945191da814c314fd1/wrapt-2.0.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8bacfe6e001749a3b64db47bcf0341da757c95959f592823a93931a422395013", size = 112096, upload-time = "2025-11-07T00:43:16.5Z" }, - { url = "https://files.pythonhosted.org/packages/7c/0b/733a2376e413117e497aa1a5b1b78e8f3a28c0e9537d26569f67d724c7c5/wrapt-2.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8ec3303e8a81932171f455f792f8df500fc1a09f20069e5c16bd7049ab4e8e38", size = 114878, upload-time = "2025-11-07T00:43:20.81Z" }, - { url = "https://files.pythonhosted.org/packages/da/03/d81dcb21bbf678fcda656495792b059f9d56677d119ca022169a12542bd0/wrapt-2.0.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:3f373a4ab5dbc528a94334f9fe444395b23c2f5332adab9ff4ea82f5a9e33bc1", size = 111298, upload-time = "2025-11-07T00:43:22.229Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d5/5e623040e8056e1108b787020d56b9be93dbbf083bf2324d42cde80f3a19/wrapt-2.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f49027b0b9503bf6c8cdc297ca55006b80c2f5dd36cecc72c6835ab6e10e8a25", size = 113361, upload-time = "2025-11-07T00:43:24.301Z" }, - { url = "https://files.pythonhosted.org/packages/a1/f3/de535ccecede6960e28c7b722e5744846258111d6c9f071aa7578ea37ad3/wrapt-2.0.1-cp310-cp310-win32.whl", hash = "sha256:8330b42d769965e96e01fa14034b28a2a7600fbf7e8f0cc90ebb36d492c993e4", size = 58035, upload-time = "2025-11-07T00:43:28.96Z" }, - { url = "https://files.pythonhosted.org/packages/21/15/39d3ca5428a70032c2ec8b1f1c9d24c32e497e7ed81aed887a4998905fcc/wrapt-2.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:1218573502a8235bb8a7ecaed12736213b22dcde9feab115fa2989d42b5ded45", size = 60383, upload-time = "2025-11-07T00:43:25.804Z" }, - { url = "https://files.pythonhosted.org/packages/43/c2/dfd23754b7f7a4dce07e08f4309c4e10a40046a83e9ae1800f2e6b18d7c1/wrapt-2.0.1-cp310-cp310-win_arm64.whl", hash = "sha256:eda8e4ecd662d48c28bb86be9e837c13e45c58b8300e43ba3c9b4fa9900302f7", size = 58894, upload-time = "2025-11-07T00:43:27.074Z" }, - { url = "https://files.pythonhosted.org/packages/98/60/553997acf3939079dab022e37b67b1904b5b0cc235503226898ba573b10c/wrapt-2.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e17283f533a0d24d6e5429a7d11f250a58d28b4ae5186f8f47853e3e70d2590", size = 77480, upload-time = "2025-11-07T00:43:30.573Z" }, - { url = "https://files.pythonhosted.org/packages/2d/50/e5b3d30895d77c52105c6d5cbf94d5b38e2a3dd4a53d22d246670da98f7c/wrapt-2.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:85df8d92158cb8f3965aecc27cf821461bb5f40b450b03facc5d9f0d4d6ddec6", size = 60690, upload-time = "2025-11-07T00:43:31.594Z" }, - { url = "https://files.pythonhosted.org/packages/f0/40/660b2898703e5cbbb43db10cdefcc294274458c3ca4c68637c2b99371507/wrapt-2.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c1be685ac7700c966b8610ccc63c3187a72e33cab53526a27b2a285a662cd4f7", size = 61578, upload-time = "2025-11-07T00:43:32.918Z" }, - { url = "https://files.pythonhosted.org/packages/5b/36/825b44c8a10556957bc0c1d84c7b29a40e05fcf1873b6c40aa9dbe0bd972/wrapt-2.0.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:df0b6d3b95932809c5b3fecc18fda0f1e07452d05e2662a0b35548985f256e28", size = 114115, upload-time = "2025-11-07T00:43:35.605Z" }, - { url = "https://files.pythonhosted.org/packages/83/73/0a5d14bb1599677304d3c613a55457d34c344e9b60eda8a737c2ead7619e/wrapt-2.0.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da7384b0e5d4cae05c97cd6f94faaf78cc8b0f791fc63af43436d98c4ab37bb", size = 116157, upload-time = "2025-11-07T00:43:37.058Z" }, - { url = "https://files.pythonhosted.org/packages/01/22/1c158fe763dbf0a119f985d945711d288994fe5514c0646ebe0eb18b016d/wrapt-2.0.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ec65a78fbd9d6f083a15d7613b2800d5663dbb6bb96003899c834beaa68b242c", size = 112535, upload-time = "2025-11-07T00:43:34.138Z" }, - { url = "https://files.pythonhosted.org/packages/5c/28/4f16861af67d6de4eae9927799b559c20ebdd4fe432e89ea7fe6fcd9d709/wrapt-2.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7de3cc939be0e1174969f943f3b44e0d79b6f9a82198133a5b7fc6cc92882f16", size = 115404, upload-time = "2025-11-07T00:43:39.214Z" }, - { url = "https://files.pythonhosted.org/packages/a0/8b/7960122e625fad908f189b59c4aae2d50916eb4098b0fb2819c5a177414f/wrapt-2.0.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:fb1a5b72cbd751813adc02ef01ada0b0d05d3dcbc32976ce189a1279d80ad4a2", size = 111802, upload-time = "2025-11-07T00:43:40.476Z" }, - { url = "https://files.pythonhosted.org/packages/3e/73/7881eee5ac31132a713ab19a22c9e5f1f7365c8b1df50abba5d45b781312/wrapt-2.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3fa272ca34332581e00bf7773e993d4f632594eb2d1b0b162a9038df0fd971dd", size = 113837, upload-time = "2025-11-07T00:43:42.921Z" }, - { url = "https://files.pythonhosted.org/packages/45/00/9499a3d14e636d1f7089339f96c4409bbc7544d0889f12264efa25502ae8/wrapt-2.0.1-cp311-cp311-win32.whl", hash = "sha256:fc007fdf480c77301ab1afdbb6ab22a5deee8885f3b1ed7afcb7e5e84a0e27be", size = 58028, upload-time = "2025-11-07T00:43:47.369Z" }, - { url = "https://files.pythonhosted.org/packages/70/5d/8f3d7eea52f22638748f74b102e38fdf88cb57d08ddeb7827c476a20b01b/wrapt-2.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:47434236c396d04875180171ee1f3815ca1eada05e24a1ee99546320d54d1d1b", size = 60385, upload-time = "2025-11-07T00:43:44.34Z" }, - { url = "https://files.pythonhosted.org/packages/14/e2/32195e57a8209003587bbbad44d5922f13e0ced2a493bb46ca882c5b123d/wrapt-2.0.1-cp311-cp311-win_arm64.whl", hash = "sha256:837e31620e06b16030b1d126ed78e9383815cbac914693f54926d816d35d8edf", size = 58893, upload-time = "2025-11-07T00:43:46.161Z" }, - { url = "https://files.pythonhosted.org/packages/cb/73/8cb252858dc8254baa0ce58ce382858e3a1cf616acebc497cb13374c95c6/wrapt-2.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1fdbb34da15450f2b1d735a0e969c24bdb8d8924892380126e2a293d9902078c", size = 78129, upload-time = "2025-11-07T00:43:48.852Z" }, - { url = "https://files.pythonhosted.org/packages/19/42/44a0db2108526ee6e17a5ab72478061158f34b08b793df251d9fbb9a7eb4/wrapt-2.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3d32794fe940b7000f0519904e247f902f0149edbe6316c710a8562fb6738841", size = 61205, upload-time = "2025-11-07T00:43:50.402Z" }, - { url = "https://files.pythonhosted.org/packages/4d/8a/5b4b1e44b791c22046e90d9b175f9a7581a8cc7a0debbb930f81e6ae8e25/wrapt-2.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:386fb54d9cd903ee0012c09291336469eb7b244f7183d40dc3e86a16a4bace62", size = 61692, upload-time = "2025-11-07T00:43:51.678Z" }, - { url = "https://files.pythonhosted.org/packages/11/53/3e794346c39f462bcf1f58ac0487ff9bdad02f9b6d5ee2dc84c72e0243b2/wrapt-2.0.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7b219cb2182f230676308cdcacd428fa837987b89e4b7c5c9025088b8a6c9faf", size = 121492, upload-time = "2025-11-07T00:43:55.017Z" }, - { url = "https://files.pythonhosted.org/packages/c6/7e/10b7b0e8841e684c8ca76b462a9091c45d62e8f2de9c4b1390b690eadf16/wrapt-2.0.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:641e94e789b5f6b4822bb8d8ebbdfc10f4e4eae7756d648b717d980f657a9eb9", size = 123064, upload-time = "2025-11-07T00:43:56.323Z" }, - { url = "https://files.pythonhosted.org/packages/0e/d1/3c1e4321fc2f5ee7fd866b2d822aa89b84495f28676fd976c47327c5b6aa/wrapt-2.0.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe21b118b9f58859b5ebaa4b130dee18669df4bd111daad082b7beb8799ad16b", size = 117403, upload-time = "2025-11-07T00:43:53.258Z" }, - { url = "https://files.pythonhosted.org/packages/a4/b0/d2f0a413cf201c8c2466de08414a15420a25aa83f53e647b7255cc2fab5d/wrapt-2.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17fb85fa4abc26a5184d93b3efd2dcc14deb4b09edcdb3535a536ad34f0b4dba", size = 121500, upload-time = "2025-11-07T00:43:57.468Z" }, - { url = "https://files.pythonhosted.org/packages/bd/45/bddb11d28ca39970a41ed48a26d210505120f925918592283369219f83cc/wrapt-2.0.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:b89ef9223d665ab255ae42cc282d27d69704d94be0deffc8b9d919179a609684", size = 116299, upload-time = "2025-11-07T00:43:58.877Z" }, - { url = "https://files.pythonhosted.org/packages/81/af/34ba6dd570ef7a534e7eec0c25e2615c355602c52aba59413411c025a0cb/wrapt-2.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a453257f19c31b31ba593c30d997d6e5be39e3b5ad9148c2af5a7314061c63eb", size = 120622, upload-time = "2025-11-07T00:43:59.962Z" }, - { url = "https://files.pythonhosted.org/packages/e2/3e/693a13b4146646fb03254636f8bafd20c621955d27d65b15de07ab886187/wrapt-2.0.1-cp312-cp312-win32.whl", hash = "sha256:3e271346f01e9c8b1130a6a3b0e11908049fe5be2d365a5f402778049147e7e9", size = 58246, upload-time = "2025-11-07T00:44:03.169Z" }, - { url = "https://files.pythonhosted.org/packages/a7/36/715ec5076f925a6be95f37917b66ebbeaa1372d1862c2ccd7a751574b068/wrapt-2.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:2da620b31a90cdefa9cd0c2b661882329e2e19d1d7b9b920189956b76c564d75", size = 60492, upload-time = "2025-11-07T00:44:01.027Z" }, - { url = "https://files.pythonhosted.org/packages/ef/3e/62451cd7d80f65cc125f2b426b25fbb6c514bf6f7011a0c3904fc8c8df90/wrapt-2.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:aea9c7224c302bc8bfc892b908537f56c430802560e827b75ecbde81b604598b", size = 58987, upload-time = "2025-11-07T00:44:02.095Z" }, - { url = "https://files.pythonhosted.org/packages/ad/fe/41af4c46b5e498c90fc87981ab2972fbd9f0bccda597adb99d3d3441b94b/wrapt-2.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:47b0f8bafe90f7736151f61482c583c86b0693d80f075a58701dd1549b0010a9", size = 78132, upload-time = "2025-11-07T00:44:04.628Z" }, - { url = "https://files.pythonhosted.org/packages/1c/92/d68895a984a5ebbbfb175512b0c0aad872354a4a2484fbd5552e9f275316/wrapt-2.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cbeb0971e13b4bd81d34169ed57a6dda017328d1a22b62fda45e1d21dd06148f", size = 61211, upload-time = "2025-11-07T00:44:05.626Z" }, - { url = "https://files.pythonhosted.org/packages/e8/26/ba83dc5ae7cf5aa2b02364a3d9cf74374b86169906a1f3ade9a2d03cf21c/wrapt-2.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb7cffe572ad0a141a7886a1d2efa5bef0bf7fe021deeea76b3ab334d2c38218", size = 61689, upload-time = "2025-11-07T00:44:06.719Z" }, - { url = "https://files.pythonhosted.org/packages/cf/67/d7a7c276d874e5d26738c22444d466a3a64ed541f6ef35f740dbd865bab4/wrapt-2.0.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c8d60527d1ecfc131426b10d93ab5d53e08a09c5fa0175f6b21b3252080c70a9", size = 121502, upload-time = "2025-11-07T00:44:09.557Z" }, - { url = "https://files.pythonhosted.org/packages/0f/6b/806dbf6dd9579556aab22fc92908a876636e250f063f71548a8660382184/wrapt-2.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c654eafb01afac55246053d67a4b9a984a3567c3808bb7df2f8de1c1caba2e1c", size = 123110, upload-time = "2025-11-07T00:44:10.64Z" }, - { url = "https://files.pythonhosted.org/packages/e5/08/cdbb965fbe4c02c5233d185d070cabed2ecc1f1e47662854f95d77613f57/wrapt-2.0.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:98d873ed6c8b4ee2418f7afce666751854d6d03e3c0ec2a399bb039cd2ae89db", size = 117434, upload-time = "2025-11-07T00:44:08.138Z" }, - { url = "https://files.pythonhosted.org/packages/2d/d1/6aae2ce39db4cb5216302fa2e9577ad74424dfbe315bd6669725569e048c/wrapt-2.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9e850f5b7fc67af856ff054c71690d54fa940c3ef74209ad9f935b4f66a0233", size = 121533, upload-time = "2025-11-07T00:44:12.142Z" }, - { url = "https://files.pythonhosted.org/packages/79/35/565abf57559fbe0a9155c29879ff43ce8bd28d2ca61033a3a3dd67b70794/wrapt-2.0.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e505629359cb5f751e16e30cf3f91a1d3ddb4552480c205947da415d597f7ac2", size = 116324, upload-time = "2025-11-07T00:44:13.28Z" }, - { url = "https://files.pythonhosted.org/packages/e1/e0/53ff5e76587822ee33e560ad55876d858e384158272cd9947abdd4ad42ca/wrapt-2.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2879af909312d0baf35f08edeea918ee3af7ab57c37fe47cb6a373c9f2749c7b", size = 120627, upload-time = "2025-11-07T00:44:14.431Z" }, - { url = "https://files.pythonhosted.org/packages/7c/7b/38df30fd629fbd7612c407643c63e80e1c60bcc982e30ceeae163a9800e7/wrapt-2.0.1-cp313-cp313-win32.whl", hash = "sha256:d67956c676be5a24102c7407a71f4126d30de2a569a1c7871c9f3cabc94225d7", size = 58252, upload-time = "2025-11-07T00:44:17.814Z" }, - { url = "https://files.pythonhosted.org/packages/85/64/d3954e836ea67c4d3ad5285e5c8fd9d362fd0a189a2db622df457b0f4f6a/wrapt-2.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:9ca66b38dd642bf90c59b6738af8070747b610115a39af2498535f62b5cdc1c3", size = 60500, upload-time = "2025-11-07T00:44:15.561Z" }, - { url = "https://files.pythonhosted.org/packages/89/4e/3c8b99ac93527cfab7f116089db120fef16aac96e5f6cdb724ddf286086d/wrapt-2.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:5a4939eae35db6b6cec8e7aa0e833dcca0acad8231672c26c2a9ab7a0f8ac9c8", size = 58993, upload-time = "2025-11-07T00:44:16.65Z" }, - { url = "https://files.pythonhosted.org/packages/f9/f4/eff2b7d711cae20d220780b9300faa05558660afb93f2ff5db61fe725b9a/wrapt-2.0.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a52f93d95c8d38fed0669da2ebdb0b0376e895d84596a976c15a9eb45e3eccb3", size = 82028, upload-time = "2025-11-07T00:44:18.944Z" }, - { url = "https://files.pythonhosted.org/packages/0c/67/cb945563f66fd0f61a999339460d950f4735c69f18f0a87ca586319b1778/wrapt-2.0.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4e54bbf554ee29fcceee24fa41c4d091398b911da6e7f5d7bffda963c9aed2e1", size = 62949, upload-time = "2025-11-07T00:44:20.074Z" }, - { url = "https://files.pythonhosted.org/packages/ec/ca/f63e177f0bbe1e5cf5e8d9b74a286537cd709724384ff20860f8f6065904/wrapt-2.0.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:908f8c6c71557f4deaa280f55d0728c3bca0960e8c3dd5ceeeafb3c19942719d", size = 63681, upload-time = "2025-11-07T00:44:21.345Z" }, - { url = "https://files.pythonhosted.org/packages/39/a1/1b88fcd21fd835dca48b556daef750952e917a2794fa20c025489e2e1f0f/wrapt-2.0.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e2f84e9af2060e3904a32cea9bb6db23ce3f91cfd90c6b426757cf7cc01c45c7", size = 152696, upload-time = "2025-11-07T00:44:24.318Z" }, - { url = "https://files.pythonhosted.org/packages/62/1c/d9185500c1960d9f5f77b9c0b890b7fc62282b53af7ad1b6bd779157f714/wrapt-2.0.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3612dc06b436968dfb9142c62e5dfa9eb5924f91120b3c8ff501ad878f90eb3", size = 158859, upload-time = "2025-11-07T00:44:25.494Z" }, - { url = "https://files.pythonhosted.org/packages/91/60/5d796ed0f481ec003220c7878a1d6894652efe089853a208ea0838c13086/wrapt-2.0.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d2d947d266d99a1477cd005b23cbd09465276e302515e122df56bb9511aca1b", size = 146068, upload-time = "2025-11-07T00:44:22.81Z" }, - { url = "https://files.pythonhosted.org/packages/04/f8/75282dd72f102ddbfba137e1e15ecba47b40acff32c08ae97edbf53f469e/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7d539241e87b650cbc4c3ac9f32c8d1ac8a54e510f6dca3f6ab60dcfd48c9b10", size = 155724, upload-time = "2025-11-07T00:44:26.634Z" }, - { url = "https://files.pythonhosted.org/packages/5a/27/fe39c51d1b344caebb4a6a9372157bdb8d25b194b3561b52c8ffc40ac7d1/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:4811e15d88ee62dbf5c77f2c3ff3932b1e3ac92323ba3912f51fc4016ce81ecf", size = 144413, upload-time = "2025-11-07T00:44:27.939Z" }, - { url = "https://files.pythonhosted.org/packages/83/2b/9f6b643fe39d4505c7bf926d7c2595b7cb4b607c8c6b500e56c6b36ac238/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c1c91405fcf1d501fa5d55df21e58ea49e6b879ae829f1039faaf7e5e509b41e", size = 150325, upload-time = "2025-11-07T00:44:29.29Z" }, - { url = "https://files.pythonhosted.org/packages/bb/b6/20ffcf2558596a7f58a2e69c89597128781f0b88e124bf5a4cadc05b8139/wrapt-2.0.1-cp313-cp313t-win32.whl", hash = "sha256:e76e3f91f864e89db8b8d2a8311d57df93f01ad6bb1e9b9976d1f2e83e18315c", size = 59943, upload-time = "2025-11-07T00:44:33.211Z" }, - { url = "https://files.pythonhosted.org/packages/87/6a/0e56111cbb3320151eed5d3821ee1373be13e05b376ea0870711f18810c3/wrapt-2.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:83ce30937f0ba0d28818807b303a412440c4b63e39d3d8fc036a94764b728c92", size = 63240, upload-time = "2025-11-07T00:44:30.935Z" }, - { url = "https://files.pythonhosted.org/packages/1d/54/5ab4c53ea1f7f7e5c3e7c1095db92932cc32fd62359d285486d00c2884c3/wrapt-2.0.1-cp313-cp313t-win_arm64.whl", hash = "sha256:4b55cacc57e1dc2d0991dbe74c6419ffd415fb66474a02335cb10efd1aa3f84f", size = 60416, upload-time = "2025-11-07T00:44:32.002Z" }, - { url = "https://files.pythonhosted.org/packages/73/81/d08d83c102709258e7730d3cd25befd114c60e43ef3891d7e6877971c514/wrapt-2.0.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:5e53b428f65ece6d9dad23cb87e64506392b720a0b45076c05354d27a13351a1", size = 78290, upload-time = "2025-11-07T00:44:34.691Z" }, - { url = "https://files.pythonhosted.org/packages/f6/14/393afba2abb65677f313aa680ff0981e829626fed39b6a7e3ec807487790/wrapt-2.0.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ad3ee9d0f254851c71780966eb417ef8e72117155cff04821ab9b60549694a55", size = 61255, upload-time = "2025-11-07T00:44:35.762Z" }, - { url = "https://files.pythonhosted.org/packages/c4/10/a4a1f2fba205a9462e36e708ba37e5ac95f4987a0f1f8fd23f0bf1fc3b0f/wrapt-2.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7b822c61ed04ee6ad64bc90d13368ad6eb094db54883b5dde2182f67a7f22c0", size = 61797, upload-time = "2025-11-07T00:44:37.22Z" }, - { url = "https://files.pythonhosted.org/packages/12/db/99ba5c37cf1c4fad35349174f1e38bd8d992340afc1ff27f526729b98986/wrapt-2.0.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7164a55f5e83a9a0b031d3ffab4d4e36bbec42e7025db560f225489fa929e509", size = 120470, upload-time = "2025-11-07T00:44:39.425Z" }, - { url = "https://files.pythonhosted.org/packages/30/3f/a1c8d2411eb826d695fc3395a431757331582907a0ec59afce8fe8712473/wrapt-2.0.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e60690ba71a57424c8d9ff28f8d006b7ad7772c22a4af432188572cd7fa004a1", size = 122851, upload-time = "2025-11-07T00:44:40.582Z" }, - { url = "https://files.pythonhosted.org/packages/b3/8d/72c74a63f201768d6a04a8845c7976f86be6f5ff4d74996c272cefc8dafc/wrapt-2.0.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3cd1a4bd9a7a619922a8557e1318232e7269b5fb69d4ba97b04d20450a6bf970", size = 117433, upload-time = "2025-11-07T00:44:38.313Z" }, - { url = "https://files.pythonhosted.org/packages/c7/5a/df37cf4042cb13b08256f8e27023e2f9b3d471d553376616591bb99bcb31/wrapt-2.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b4c2e3d777e38e913b8ce3a6257af72fb608f86a1df471cb1d4339755d0a807c", size = 121280, upload-time = "2025-11-07T00:44:41.69Z" }, - { url = "https://files.pythonhosted.org/packages/54/34/40d6bc89349f9931e1186ceb3e5fbd61d307fef814f09fbbac98ada6a0c8/wrapt-2.0.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3d366aa598d69416b5afedf1faa539fac40c1d80a42f6b236c88c73a3c8f2d41", size = 116343, upload-time = "2025-11-07T00:44:43.013Z" }, - { url = "https://files.pythonhosted.org/packages/70/66/81c3461adece09d20781dee17c2366fdf0cb8754738b521d221ca056d596/wrapt-2.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c235095d6d090aa903f1db61f892fffb779c1eaeb2a50e566b52001f7a0f66ed", size = 119650, upload-time = "2025-11-07T00:44:44.523Z" }, - { url = "https://files.pythonhosted.org/packages/46/3a/d0146db8be8761a9e388cc9cc1c312b36d583950ec91696f19bbbb44af5a/wrapt-2.0.1-cp314-cp314-win32.whl", hash = "sha256:bfb5539005259f8127ea9c885bdc231978c06b7a980e63a8a61c8c4c979719d0", size = 58701, upload-time = "2025-11-07T00:44:48.277Z" }, - { url = "https://files.pythonhosted.org/packages/1a/38/5359da9af7d64554be63e9046164bd4d8ff289a2dd365677d25ba3342c08/wrapt-2.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:4ae879acc449caa9ed43fc36ba08392b9412ee67941748d31d94e3cedb36628c", size = 60947, upload-time = "2025-11-07T00:44:46.086Z" }, - { url = "https://files.pythonhosted.org/packages/aa/3f/96db0619276a833842bf36343685fa04f987dd6e3037f314531a1e00492b/wrapt-2.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:8639b843c9efd84675f1e100ed9e99538ebea7297b62c4b45a7042edb84db03e", size = 59359, upload-time = "2025-11-07T00:44:47.164Z" }, - { url = "https://files.pythonhosted.org/packages/71/49/5f5d1e867bf2064bf3933bc6cf36ade23505f3902390e175e392173d36a2/wrapt-2.0.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:9219a1d946a9b32bb23ccae66bdb61e35c62773ce7ca6509ceea70f344656b7b", size = 82031, upload-time = "2025-11-07T00:44:49.4Z" }, - { url = "https://files.pythonhosted.org/packages/2b/89/0009a218d88db66ceb83921e5685e820e2c61b59bbbb1324ba65342668bc/wrapt-2.0.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fa4184e74197af3adad3c889a1af95b53bb0466bced92ea99a0c014e48323eec", size = 62952, upload-time = "2025-11-07T00:44:50.74Z" }, - { url = "https://files.pythonhosted.org/packages/ae/18/9b968e920dd05d6e44bcc918a046d02afea0fb31b2f1c80ee4020f377cbe/wrapt-2.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c5ef2f2b8a53b7caee2f797ef166a390fef73979b15778a4a153e4b5fedce8fa", size = 63688, upload-time = "2025-11-07T00:44:52.248Z" }, - { url = "https://files.pythonhosted.org/packages/a6/7d/78bdcb75826725885d9ea26c49a03071b10c4c92da93edda612910f150e4/wrapt-2.0.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e042d653a4745be832d5aa190ff80ee4f02c34b21f4b785745eceacd0907b815", size = 152706, upload-time = "2025-11-07T00:44:54.613Z" }, - { url = "https://files.pythonhosted.org/packages/dd/77/cac1d46f47d32084a703df0d2d29d47e7eb2a7d19fa5cbca0e529ef57659/wrapt-2.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2afa23318136709c4b23d87d543b425c399887b4057936cd20386d5b1422b6fa", size = 158866, upload-time = "2025-11-07T00:44:55.79Z" }, - { url = "https://files.pythonhosted.org/packages/8a/11/b521406daa2421508903bf8d5e8b929216ec2af04839db31c0a2c525eee0/wrapt-2.0.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c72328f668cf4c503ffcf9434c2b71fdd624345ced7941bc6693e61bbe36bef", size = 146148, upload-time = "2025-11-07T00:44:53.388Z" }, - { url = "https://files.pythonhosted.org/packages/0c/c0/340b272bed297baa7c9ce0c98ef7017d9c035a17a6a71dce3184b8382da2/wrapt-2.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3793ac154afb0e5b45d1233cb94d354ef7a983708cc3bb12563853b1d8d53747", size = 155737, upload-time = "2025-11-07T00:44:56.971Z" }, - { url = "https://files.pythonhosted.org/packages/f3/93/bfcb1fb2bdf186e9c2883a4d1ab45ab099c79cbf8f4e70ea453811fa3ea7/wrapt-2.0.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fec0d993ecba3991645b4857837277469c8cc4c554a7e24d064d1ca291cfb81f", size = 144451, upload-time = "2025-11-07T00:44:58.515Z" }, - { url = "https://files.pythonhosted.org/packages/d2/6b/dca504fb18d971139d232652656180e3bd57120e1193d9a5899c3c0b7cdd/wrapt-2.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:949520bccc1fa227274da7d03bf238be15389cd94e32e4297b92337df9b7a349", size = 150353, upload-time = "2025-11-07T00:44:59.753Z" }, - { url = "https://files.pythonhosted.org/packages/1d/f6/a1de4bd3653afdf91d250ca5c721ee51195df2b61a4603d4b373aa804d1d/wrapt-2.0.1-cp314-cp314t-win32.whl", hash = "sha256:be9e84e91d6497ba62594158d3d31ec0486c60055c49179edc51ee43d095f79c", size = 60609, upload-time = "2025-11-07T00:45:03.315Z" }, - { url = "https://files.pythonhosted.org/packages/01/3a/07cd60a9d26fe73efead61c7830af975dfdba8537632d410462672e4432b/wrapt-2.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:61c4956171c7434634401db448371277d07032a81cc21c599c22953374781395", size = 64038, upload-time = "2025-11-07T00:45:00.948Z" }, - { url = "https://files.pythonhosted.org/packages/41/99/8a06b8e17dddbf321325ae4eb12465804120f699cd1b8a355718300c62da/wrapt-2.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:35cdbd478607036fee40273be8ed54a451f5f23121bd9d4be515158f9498f7ad", size = 60634, upload-time = "2025-11-07T00:45:02.087Z" }, - { url = "https://files.pythonhosted.org/packages/15/d1/b51471c11592ff9c012bd3e2f7334a6ff2f42a7aed2caffcf0bdddc9cb89/wrapt-2.0.1-py3-none-any.whl", hash = "sha256:4d2ce1bf1a48c5277d7969259232b57645aae5686dba1eaeade39442277afbca", size = 44046, upload-time = "2025-11-07T00:45:32.116Z" }, +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/37/ae31f40bec90de2f88d9597d0b5281e23ffe85b893a47ca5d9c05c63a4f6/wrapt-2.1.1.tar.gz", hash = "sha256:5fdcb09bf6db023d88f312bd0767594b414655d58090fc1c46b3414415f67fac", size = 81329, upload-time = "2026-02-03T02:12:13.786Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/21/293b657a27accfbbbb6007ebd78af0efa2083dac83e8f523272ea09b4638/wrapt-2.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7e927375e43fd5a985b27a8992327c22541b6dede1362fc79df337d26e23604f", size = 60554, upload-time = "2026-02-03T02:11:17.362Z" }, + { url = "https://files.pythonhosted.org/packages/25/e9/96dd77728b54a899d4ce2798d7b1296989ce687ed3c0cb917d6b3154bf5d/wrapt-2.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c99544b6a7d40ca22195563b6d8bc3986ee8bb82f272f31f0670fe9440c869", size = 61496, upload-time = "2026-02-03T02:12:54.732Z" }, + { url = "https://files.pythonhosted.org/packages/44/79/4c755b45df6ef30c0dd628ecfaa0c808854be147ca438429da70a162833c/wrapt-2.1.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b2be3fa5f4efaf16ee7c77d0556abca35f5a18ad4ac06f0ef3904c3399010ce9", size = 113528, upload-time = "2026-02-03T02:12:26.405Z" }, + { url = "https://files.pythonhosted.org/packages/9f/63/23ce28f7b841217d9a6337a340fbb8d4a7fbd67a89d47f377c8550fa34aa/wrapt-2.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67c90c1ae6489a6cb1a82058902caa8006706f7b4e8ff766f943e9d2c8e608d0", size = 115536, upload-time = "2026-02-03T02:11:54.397Z" }, + { url = "https://files.pythonhosted.org/packages/23/7b/5ca8d3b12768670d16c8329e29960eedd56212770365a02a8de8bf73dc01/wrapt-2.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:05c0db35ccffd7480143e62df1e829d101c7b86944ae3be7e4869a7efa621f53", size = 114716, upload-time = "2026-02-03T02:12:20.771Z" }, + { url = "https://files.pythonhosted.org/packages/c7/3a/9789ccb14a096d30bb847bf3ee137bf682cc9750c2ce155f4c5ae1962abf/wrapt-2.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0c2ec9f616755b2e1e0bf4d0961f59bb5c2e7a77407e7e2c38ef4f7d2fdde12c", size = 113200, upload-time = "2026-02-03T02:12:07.688Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e5/4ec3526ce6ce920b267c8d35d2c2f0874d3fad2744c8b7259353f1132baa/wrapt-2.1.1-cp310-cp310-win32.whl", hash = "sha256:203ba6b3f89e410e27dbd30ff7dccaf54dcf30fda0b22aa1b82d560c7f9fe9a1", size = 57876, upload-time = "2026-02-03T02:11:42.61Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4e/661c7c76ecd85375b2bc03488941a3a1078642af481db24949e2b9de01f4/wrapt-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:6f9426d9cfc2f8732922fc96198052e55c09bb9db3ddaa4323a18e055807410e", size = 60224, upload-time = "2026-02-03T02:11:19.096Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b7/53c7252d371efada4cb119e72e774fa2c6b3011fc33e3e552cdf48fb9488/wrapt-2.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:69c26f51b67076b40714cff81bdd5826c0b10c077fb6b0678393a6a2f952a5fc", size = 58645, upload-time = "2026-02-03T02:12:10.396Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a8/9254e4da74b30a105935197015b18b31b7a298bf046e67d8952ef74967bd/wrapt-2.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6c366434a7fb914c7a5de508ed735ef9c133367114e1a7cb91dfb5cd806a1549", size = 60554, upload-time = "2026-02-03T02:11:13.038Z" }, + { url = "https://files.pythonhosted.org/packages/9e/a1/378579880cc7af226354054a2c255f69615b379d8adad482bfe2f22a0dc2/wrapt-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5d6a2068bd2e1e19e5a317c8c0b288267eec4e7347c36bc68a6e378a39f19ee7", size = 61491, upload-time = "2026-02-03T02:12:56.077Z" }, + { url = "https://files.pythonhosted.org/packages/dc/72/957b51c56acca35701665878ad31626182199fc4afecfe67dea072210f95/wrapt-2.1.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:891ab4713419217b2aed7dd106c9200f64e6a82226775a0d2ebd6bef2ebd1747", size = 113949, upload-time = "2026-02-03T02:11:04.516Z" }, + { url = "https://files.pythonhosted.org/packages/cd/74/36bbebb4a3d2ae9c3e6929639721f8606cd0710a82a777c371aa69e36504/wrapt-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8ef36a0df38d2dc9d907f6617f89e113c5892e0a35f58f45f75901af0ce7d81", size = 115989, upload-time = "2026-02-03T02:12:19.398Z" }, + { url = "https://files.pythonhosted.org/packages/ae/0d/f1177245a083c7be284bc90bddfe5aece32cdd5b858049cb69ce001a0e8d/wrapt-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:76e9af3ebd86f19973143d4d592cbf3e970cf3f66ddee30b16278c26ae34b8ab", size = 115242, upload-time = "2026-02-03T02:11:08.111Z" }, + { url = "https://files.pythonhosted.org/packages/62/3e/3b7cf5da27e59df61b1eae2d07dd03ff5d6f75b5408d694873cca7a8e33c/wrapt-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ff562067485ebdeaef2fa3fe9b1876bc4e7b73762e0a01406ad81e2076edcebf", size = 113676, upload-time = "2026-02-03T02:12:41.026Z" }, + { url = "https://files.pythonhosted.org/packages/f7/65/8248d3912c705f2c66f81cb97c77436f37abcbedb16d633b5ab0d795d8cd/wrapt-2.1.1-cp311-cp311-win32.whl", hash = "sha256:9e60a30aa0909435ec4ea2a3c53e8e1b50ac9f640c0e9fe3f21fd248a22f06c5", size = 57863, upload-time = "2026-02-03T02:12:18.112Z" }, + { url = "https://files.pythonhosted.org/packages/6b/31/d29310ab335f71f00c50466153b3dc985aaf4a9fc03263e543e136859541/wrapt-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:7d79954f51fcf84e5ec4878ab4aea32610d70145c5bbc84b3370eabfb1e096c2", size = 60224, upload-time = "2026-02-03T02:12:29.289Z" }, + { url = "https://files.pythonhosted.org/packages/0c/90/a6ec319affa6e2894962a0cb9d73c67f88af1a726d15314bfb5c88b8a08d/wrapt-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:d3ffc6b0efe79e08fd947605fd598515aebefe45e50432dc3b5cd437df8b1ada", size = 58643, upload-time = "2026-02-03T02:12:43.022Z" }, + { url = "https://files.pythonhosted.org/packages/df/cb/4d5255d19bbd12be7f8ee2c1fb4269dddec9cef777ef17174d357468efaa/wrapt-2.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab8e3793b239db021a18782a5823fcdea63b9fe75d0e340957f5828ef55fcc02", size = 61143, upload-time = "2026-02-03T02:11:46.313Z" }, + { url = "https://files.pythonhosted.org/packages/6f/07/7ed02daa35542023464e3c8b7cb937fa61f6c61c0361ecf8f5fecf8ad8da/wrapt-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7c0300007836373d1c2df105b40777986accb738053a92fe09b615a7a4547e9f", size = 61740, upload-time = "2026-02-03T02:12:51.966Z" }, + { url = "https://files.pythonhosted.org/packages/c4/60/a237a4e4a36f6d966061ccc9b017627d448161b19e0a3ab80a7c7c97f859/wrapt-2.1.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2b27c070fd1132ab23957bcd4ee3ba707a91e653a9268dc1afbd39b77b2799f7", size = 121327, upload-time = "2026-02-03T02:11:06.796Z" }, + { url = "https://files.pythonhosted.org/packages/ae/fe/9139058a3daa8818fc67e6460a2340e8bbcf3aef8b15d0301338bbe181ca/wrapt-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b0e36d845e8b6f50949b6b65fc6cd279f47a1944582ed4ec8258cd136d89a64", size = 122903, upload-time = "2026-02-03T02:12:48.657Z" }, + { url = "https://files.pythonhosted.org/packages/91/10/b8479202b4164649675846a531763531f0a6608339558b5a0a718fc49a8d/wrapt-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4aeea04a9889370fcfb1ef828c4cc583f36a875061505cd6cd9ba24d8b43cc36", size = 121333, upload-time = "2026-02-03T02:11:32.148Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/75fc793b791d79444aca2c03ccde64e8b99eda321b003f267d570b7b0985/wrapt-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d88b46bb0dce9f74b6817bc1758ff2125e1ca9e1377d62ea35b6896142ab6825", size = 120458, upload-time = "2026-02-03T02:11:16.039Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8f/c3f30d511082ca6d947c405f9d8f6c8eaf83cfde527c439ec2c9a30eb5ea/wrapt-2.1.1-cp312-cp312-win32.whl", hash = "sha256:63decff76ca685b5c557082dfbea865f3f5f6d45766a89bff8dc61d336348833", size = 58086, upload-time = "2026-02-03T02:12:35.041Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c8/37625b643eea2849f10c3b90f69c7462faa4134448d4443234adaf122ae5/wrapt-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:b828235d26c1e35aca4107039802ae4b1411be0fe0367dd5b7e4d90e562fcbcd", size = 60328, upload-time = "2026-02-03T02:12:45.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/79/56242f07572d5682ba8065a9d4d9c2218313f576e3c3471873c2a5355ffd/wrapt-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:75128507413a9f1bcbe2db88fd18fbdbf80f264b82fa33a6996cdeaf01c52352", size = 58722, upload-time = "2026-02-03T02:12:27.949Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ca/3cf290212855b19af9fcc41b725b5620b32f470d6aad970c2593500817eb/wrapt-2.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ce9646e17fa7c3e2e7a87e696c7de66512c2b4f789a8db95c613588985a2e139", size = 61150, upload-time = "2026-02-03T02:12:50.575Z" }, + { url = "https://files.pythonhosted.org/packages/9d/33/5b8f89a82a9859ce82da4870c799ad11ce15648b6e1c820fec3e23f4a19f/wrapt-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:428cfc801925454395aa468ba7ddb3ed63dc0d881df7b81626cdd433b4e2b11b", size = 61743, upload-time = "2026-02-03T02:11:55.733Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2f/60c51304fbdf47ce992d9eefa61fbd2c0e64feee60aaa439baf42ea6f40b/wrapt-2.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5797f65e4d58065a49088c3b32af5410751cd485e83ba89e5a45e2aa8905af98", size = 121341, upload-time = "2026-02-03T02:11:20.461Z" }, + { url = "https://files.pythonhosted.org/packages/ad/03/ce5256e66dd94e521ad5e753c78185c01b6eddbed3147be541f4d38c0cb7/wrapt-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a2db44a71202c5ae4bb5f27c6d3afbc5b23053f2e7e78aa29704541b5dad789", size = 122947, upload-time = "2026-02-03T02:11:33.596Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ae/50ca8854b81b946a11a36fcd6ead32336e6db2c14b6e4a8b092b80741178/wrapt-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8d5350c3590af09c1703dd60ec78a7370c0186e11eaafb9dda025a30eee6492d", size = 121370, upload-time = "2026-02-03T02:11:09.886Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d9/d6a7c654e0043319b4cc137a4caaf7aa16b46b51ee8df98d1060254705b7/wrapt-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d9b076411bed964e752c01b49fd224cc385f3a96f520c797d38412d70d08359", size = 120465, upload-time = "2026-02-03T02:11:37.592Z" }, + { url = "https://files.pythonhosted.org/packages/55/90/65be41e40845d951f714b5a77e84f377a3787b1e8eee6555a680da6d0db5/wrapt-2.1.1-cp313-cp313-win32.whl", hash = "sha256:0bb7207130ce6486727baa85373503bf3334cc28016f6928a0fa7e19d7ecdc06", size = 58090, upload-time = "2026-02-03T02:12:53.342Z" }, + { url = "https://files.pythonhosted.org/packages/5f/66/6a09e0294c4fc8c26028a03a15191721c9271672467cc33e6617ee0d91d2/wrapt-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:cbfee35c711046b15147b0ae7db9b976f01c9520e6636d992cd9e69e5e2b03b1", size = 60341, upload-time = "2026-02-03T02:12:36.384Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f0/20ceb8b701e9a71555c87a5ddecbed76ec16742cf1e4b87bbaf26735f998/wrapt-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:7d2756061022aebbf57ba14af9c16e8044e055c22d38de7bf40d92b565ecd2b0", size = 58731, upload-time = "2026-02-03T02:12:01.328Z" }, + { url = "https://files.pythonhosted.org/packages/80/b4/fe95beb8946700b3db371f6ce25115217e7075ca063663b8cca2888ba55c/wrapt-2.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4814a3e58bc6971e46baa910ecee69699110a2bf06c201e24277c65115a20c20", size = 62969, upload-time = "2026-02-03T02:11:51.245Z" }, + { url = "https://files.pythonhosted.org/packages/b8/89/477b0bdc784e3299edf69c279697372b8bd4c31d9c6966eae405442899df/wrapt-2.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:106c5123232ab9b9f4903692e1fa0bdc231510098f04c13c3081f8ad71c3d612", size = 63606, upload-time = "2026-02-03T02:12:02.64Z" }, + { url = "https://files.pythonhosted.org/packages/ed/55/9d0c1269ab76de87715b3b905df54dd25d55bbffd0b98696893eb613469f/wrapt-2.1.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1a40b83ff2535e6e56f190aff123821eea89a24c589f7af33413b9c19eb2c738", size = 152536, upload-time = "2026-02-03T02:11:24.492Z" }, + { url = "https://files.pythonhosted.org/packages/44/18/2004766030462f79ad86efaa62000b5e39b1ff001dcce86650e1625f40ae/wrapt-2.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:789cea26e740d71cf1882e3a42bb29052bc4ada15770c90072cb47bf73fb3dbf", size = 158697, upload-time = "2026-02-03T02:12:32.214Z" }, + { url = "https://files.pythonhosted.org/packages/e1/bb/0a880fa0f35e94ee843df4ee4dd52a699c9263f36881311cfb412c09c3e5/wrapt-2.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ba49c14222d5e5c0ee394495a8655e991dc06cbca5398153aefa5ac08cd6ccd7", size = 155563, upload-time = "2026-02-03T02:11:49.737Z" }, + { url = "https://files.pythonhosted.org/packages/42/ff/cd1b7c4846c8678fac359a6eb975dc7ab5bd606030adb22acc8b4a9f53f1/wrapt-2.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ac8cda531fe55be838a17c62c806824472bb962b3afa47ecbd59b27b78496f4e", size = 150161, upload-time = "2026-02-03T02:12:33.613Z" }, + { url = "https://files.pythonhosted.org/packages/38/ec/67c90a7082f452964b4621e4890e9a490f1add23cdeb7483cc1706743291/wrapt-2.1.1-cp313-cp313t-win32.whl", hash = "sha256:b8af75fe20d381dd5bcc9db2e86a86d7fcfbf615383a7147b85da97c1182225b", size = 59783, upload-time = "2026-02-03T02:11:39.863Z" }, + { url = "https://files.pythonhosted.org/packages/ec/08/466afe4855847d8febdfa2c57c87e991fc5820afbdef01a273683dfd15a0/wrapt-2.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:45c5631c9b6c792b78be2d7352129f776dd72c605be2c3a4e9be346be8376d83", size = 63082, upload-time = "2026-02-03T02:12:09.075Z" }, + { url = "https://files.pythonhosted.org/packages/9a/62/60b629463c28b15b1eeadb3a0691e17568622b12aa5bfa7ebe9b514bfbeb/wrapt-2.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:da815b9263947ac98d088b6414ac83507809a1d385e4632d9489867228d6d81c", size = 60251, upload-time = "2026-02-03T02:11:21.794Z" }, + { url = "https://files.pythonhosted.org/packages/95/a0/1c2396e272f91efe6b16a6a8bce7ad53856c8f9ae4f34ceaa711d63ec9e1/wrapt-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aa1765054245bb01a37f615503290d4e207e3fd59226e78341afb587e9c1236", size = 61311, upload-time = "2026-02-03T02:12:44.41Z" }, + { url = "https://files.pythonhosted.org/packages/b0/9a/d2faba7e61072a7507b5722db63562fdb22f5a24e237d460d18755627f15/wrapt-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:feff14b63a6d86c1eee33a57f77573649f2550935981625be7ff3cb7342efe05", size = 61805, upload-time = "2026-02-03T02:11:59.905Z" }, + { url = "https://files.pythonhosted.org/packages/db/56/073989deb4b5d7d6e7ea424476a4ae4bda02140f2dbeaafb14ba4864dd60/wrapt-2.1.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:81fc5f22d5fcfdbabde96bb3f5379b9f4476d05c6d524d7259dc5dfb501d3281", size = 120308, upload-time = "2026-02-03T02:12:04.46Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b6/84f37261295e38167a29eb82affaf1dc15948dc416925fe2091beee8e4ac/wrapt-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:951b228ecf66def855d22e006ab9a1fc12535111ae7db2ec576c728f8ddb39e8", size = 122688, upload-time = "2026-02-03T02:11:23.148Z" }, + { url = "https://files.pythonhosted.org/packages/ea/80/32db2eec6671f80c65b7ff175be61bc73d7f5223f6910b0c921bbc4bd11c/wrapt-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ddf582a95641b9a8c8bd643e83f34ecbbfe1b68bc3850093605e469ab680ae3", size = 121115, upload-time = "2026-02-03T02:12:39.068Z" }, + { url = "https://files.pythonhosted.org/packages/49/ef/dcd00383df0cd696614127902153bf067971a5aabcd3c9dcb2d8ef354b2a/wrapt-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fc5c500966bf48913f795f1984704e6d452ba2414207b15e1f8c339a059d5b16", size = 119484, upload-time = "2026-02-03T02:11:48.419Z" }, + { url = "https://files.pythonhosted.org/packages/76/29/0630280cdd2bd8f86f35cb6854abee1c9d6d1a28a0c6b6417cd15d378325/wrapt-2.1.1-cp314-cp314-win32.whl", hash = "sha256:4aa4baadb1f94b71151b8e44a0c044f6af37396c3b8bcd474b78b49e2130a23b", size = 58514, upload-time = "2026-02-03T02:11:58.616Z" }, + { url = "https://files.pythonhosted.org/packages/db/19/5bed84f9089ed2065f6aeda5dfc4f043743f642bc871454b261c3d7d322b/wrapt-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:860e9d3fd81816a9f4e40812f28be4439ab01f260603c749d14be3c0a1170d19", size = 60763, upload-time = "2026-02-03T02:12:24.553Z" }, + { url = "https://files.pythonhosted.org/packages/e4/cb/b967f2f9669e4249b4fe82e630d2a01bc6b9e362b9b12ed91bbe23ae8df4/wrapt-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3c59e103017a2c1ea0ddf589cbefd63f91081d7ce9d491d69ff2512bb1157e23", size = 59051, upload-time = "2026-02-03T02:11:29.602Z" }, + { url = "https://files.pythonhosted.org/packages/eb/19/6fed62be29f97eb8a56aff236c3f960a4b4a86e8379dc7046a8005901a97/wrapt-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9fa7c7e1bee9278fc4f5dd8275bc8d25493281a8ec6c61959e37cc46acf02007", size = 63059, upload-time = "2026-02-03T02:12:06.368Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1c/b757fd0adb53d91547ed8fad76ba14a5932d83dde4c994846a2804596378/wrapt-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:39c35e12e8215628984248bd9c8897ce0a474be2a773db207eb93414219d8469", size = 63618, upload-time = "2026-02-03T02:12:23.197Z" }, + { url = "https://files.pythonhosted.org/packages/10/fe/e5ae17b1480957c7988d991b93df9f2425fc51f128cf88144d6a18d0eb12/wrapt-2.1.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:94ded4540cac9125eaa8ddf5f651a7ec0da6f5b9f248fe0347b597098f8ec14c", size = 152544, upload-time = "2026-02-03T02:11:43.915Z" }, + { url = "https://files.pythonhosted.org/packages/3e/cc/99aed210c6b547b8a6e4cb9d1425e4466727158a6aeb833aa7997e9e08dd/wrapt-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da0af328373f97ed9bdfea24549ac1b944096a5a71b30e41c9b8b53ab3eec04a", size = 158700, upload-time = "2026-02-03T02:12:30.684Z" }, + { url = "https://files.pythonhosted.org/packages/81/0e/d442f745f4957944d5f8ad38bc3a96620bfff3562533b87e486e979f3d99/wrapt-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4ad839b55f0bf235f8e337ce060572d7a06592592f600f3a3029168e838469d3", size = 155561, upload-time = "2026-02-03T02:11:28.164Z" }, + { url = "https://files.pythonhosted.org/packages/51/ac/9891816280e0018c48f8dfd61b136af7b0dcb4a088895db2531acde5631b/wrapt-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0d89c49356e5e2a50fa86b40e0510082abcd0530f926cbd71cf25bee6b9d82d7", size = 150188, upload-time = "2026-02-03T02:11:57.053Z" }, + { url = "https://files.pythonhosted.org/packages/24/98/e2f273b6d70d41f98d0739aa9a269d0b633684a5fb17b9229709375748d4/wrapt-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:f4c7dd22cf7f36aafe772f3d88656559205c3af1b7900adfccb70edeb0d2abc4", size = 60425, upload-time = "2026-02-03T02:11:35.007Z" }, + { url = "https://files.pythonhosted.org/packages/1e/06/b500bfc38a4f82d89f34a13069e748c82c5430d365d9e6b75afb3ab74457/wrapt-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f76bc12c583ab01e73ba0ea585465a41e48d968f6d1311b4daec4f8654e356e3", size = 63855, upload-time = "2026-02-03T02:12:15.47Z" }, + { url = "https://files.pythonhosted.org/packages/d9/cc/5f6193c32166faee1d2a613f278608e6f3b95b96589d020f0088459c46c9/wrapt-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7ea74fc0bec172f1ae5f3505b6655c541786a5cabe4bbc0d9723a56ac32eb9b9", size = 60443, upload-time = "2026-02-03T02:11:30.869Z" }, + { url = "https://files.pythonhosted.org/packages/c4/da/5a086bf4c22a41995312db104ec2ffeee2cf6accca9faaee5315c790377d/wrapt-2.1.1-py3-none-any.whl", hash = "sha256:3b0f4629eb954394a3d7c7a1c8cca25f0b07cefe6aa8545e862e9778152de5b7", size = 43886, upload-time = "2026-02-03T02:11:45.048Z" }, ] From 59c62f82aa44ef7b77e7df2ddbea1b3964abc531 Mon Sep 17 00:00:00 2001 From: Jeff Coggshall Date: Sun, 15 Feb 2026 16:17:07 -0800 Subject: [PATCH 2/5] tts gateway: route tasks via generate/generate_stream contracts --- mlx_audio/tts/generate.py | 263 ++++++++++++++++- .../tests/test_generate_stream_contracts.py | 266 ++++++++++++++++++ 2 files changed, 522 insertions(+), 7 deletions(-) create mode 100644 mlx_audio/tts/tests/test_generate_stream_contracts.py diff --git a/mlx_audio/tts/generate.py b/mlx_audio/tts/generate.py index de3e03a4a..6d53833e1 100644 --- a/mlx_audio/tts/generate.py +++ b/mlx_audio/tts/generate.py @@ -1,7 +1,8 @@ import argparse +import json import os import sys -from typing import Optional, Tuple, Union +from typing import Any, Optional, Tuple, Union import mlx.core as mx import mlx.nn as nn @@ -103,11 +104,23 @@ def hertz_to_mel(pitch: float) -> float: def generate_audio( - text: str, + text: Optional[str], model: Optional[Union[str, nn.Module]] = None, max_tokens: int = 1200, + tokens: Optional[int] = None, + duration_s: Optional[float] = None, + seconds: Optional[float] = None, + n_vq_for_inference: Optional[int] = None, voice: str = "af_heart", instruct: Optional[str] = None, + quality: Optional[str] = None, + sound_event: Optional[str] = None, + ambient_sound: Optional[str] = None, + language: Optional[str] = None, + preset: Optional[str] = None, + model_kwargs_json: Optional[Union[str, dict[str, Any]]] = None, + dialogue_speakers_json: Optional[str] = None, + input_type: str = "text", speed: float = 1.0, lang_code: str = "en", cfg_scale: Optional[float] = None, @@ -124,8 +137,18 @@ def generate_audio( play: bool = False, verbose: bool = True, temperature: float = 0.7, + seed: Optional[int] = None, + repetition_window: Optional[int] = None, stream: bool = False, streaming_interval: float = 2.0, + long_form: bool = False, + long_form_min_chars: int = 160, + long_form_target_chars: int = 320, + long_form_max_chars: int = 520, + long_form_prefix_audio_seconds: float = 2.0, + long_form_prefix_audio_max_tokens: int = 25, + long_form_prefix_text_chars: int = 0, + long_form_retry_attempts: int = 0, **kwargs, ) -> None: """ @@ -154,7 +177,15 @@ def generate_audio( - None: The function writes the generated audio to a file. """ try: - play = play or stream + if seed is not None: + mx.random.seed(int(seed)) + + # Keep streaming generation usable in headless/non-interactive runs. + # Playback remains explicitly opt-in via --play. + play = bool(play) + + if (text is None or text.strip() == "") and ambient_sound: + text = ambient_sound if model is None: raise ValueError("Model path or model instance must be provided.") @@ -205,10 +236,29 @@ def generate_audio( if output_path: os.makedirs(output_path, exist_ok=True) file_prefix = os.path.join(output_path, file_prefix) + has_stream_output_sink = bool(output_path) + if stream and not play and not has_stream_output_sink: + raise ValueError( + "Streaming mode requires at least one sink: enable --play or provide --output_path." + ) if instruct is not None: print(f"\033[94mInstruct:\033[0m {instruct}") + dialogue_speakers: Optional[list[dict[str, Any]]] = None + if dialogue_speakers_json is not None: + with open(dialogue_speakers_json, "r", encoding="utf-8") as f: + raw = json.load(f) + if not isinstance(raw, list): + raise ValueError("dialogue_speakers_json must contain a JSON list") + dialogue_speakers = [] + for item in raw: + if not isinstance(item, dict): + raise ValueError( + "Each dialogue_speakers item must be a JSON object" + ) + dialogue_speakers.append(item) + print( f"\033[94mText:\033[0m {text}\n" f"\033[94mVoice:\033[0m {voice}\n" @@ -225,7 +275,18 @@ def generate_audio( ref_text=ref_text, cfg_scale=cfg_scale, ddpm_steps=ddpm_steps, + tokens=tokens, + duration_s=duration_s if duration_s is not None else seconds, + quality=quality, + sound_event=sound_event, + ambient_sound=ambient_sound, + language=language, + preset=preset, + n_vq_for_inference=n_vq_for_inference, + dialogue_speakers=dialogue_speakers, + input_type=input_type, temperature=temperature, + repetition_window=repetition_window, max_tokens=max_tokens, verbose=verbose, stream=stream, @@ -233,6 +294,36 @@ def generate_audio( instruct=instruct, **kwargs, ) + if model_kwargs_json is not None: + parsed_kwargs = model_kwargs_json + if isinstance(parsed_kwargs, str): + try: + parsed_kwargs = json.loads(parsed_kwargs) + except json.JSONDecodeError as exc: + raise ValueError( + f"Invalid --model_kwargs_json value: {exc.msg}" + ) from exc + if not isinstance(parsed_kwargs, dict): + raise ValueError("--model_kwargs_json must decode to a JSON object") + gen_kwargs.update(parsed_kwargs) + + if long_form: + gen_kwargs.update( + { + "long_form": True, + "long_form_min_chars": int(long_form_min_chars), + "long_form_target_chars": int(long_form_target_chars), + "long_form_max_chars": int(long_form_max_chars), + "long_form_prefix_audio_seconds": float( + long_form_prefix_audio_seconds + ), + "long_form_prefix_audio_max_tokens": int( + long_form_prefix_audio_max_tokens + ), + "long_form_prefix_text_chars": int(long_form_prefix_text_chars), + "long_form_retry_attempts": int(long_form_retry_attempts), + } + ) results = model.generate(**gen_kwargs) @@ -242,9 +333,20 @@ def generate_audio( if play: player.queue_audio(result.audio) - if join_audio: + if join_audio and not stream: audio_list.append(result.audio) - elif not stream: + if stream and has_stream_output_sink: + file_name = f"{file_prefix}_{i:03d}.{audio_format}" + audio_write( + file_name, + np.array(result.audio), + result.sample_rate, + format=audio_format, + ) + print( + f"✅ Stream chunk successfully generated and saved as: {file_name}" + ) + elif not stream and not join_audio: file_name = f"{file_prefix}_{i:03d}.{audio_format}" audio_write( file_name, @@ -274,14 +376,18 @@ def generate_audio( if join_audio and not stream: if verbose: print(f"Joining {len(audio_list)} audio files") + joined_file_name = f"{file_prefix}.{audio_format}" audio = mx.concatenate(audio_list, axis=0) audio_write( - f"{file_prefix}.{audio_format}", + joined_file_name, audio, model.sample_rate, + format=audio_format, ) if verbose: - print(f"✅ Audio successfully generated and saving as: {file_name}") + print( + f"✅ Audio successfully generated and saving as: {joined_file_name}" + ) if play: player.wait_for_drain() @@ -313,6 +419,32 @@ def parse_args(): default=1200, help="Maximum number of tokens to generate", ) + parser.add_argument( + "--tokens", + type=int, + default=None, + help="Target duration control for models that support token-based timing", + ) + parser.add_argument( + "--duration_s", + "--seconds", + dest="duration_s", + type=float, + default=None, + help=( + "Convenience duration control in seconds (mapped to tokens at 12.5 Hz; " + "ignored when --tokens is provided)" + ), + ) + parser.add_argument( + "--n_vq_for_inference", + type=int, + default=None, + help=( + "Local-only inference depth override (1..n_vq) for quality/performance " + "trade-offs" + ), + ) parser.add_argument( "--text", type=str, @@ -331,6 +463,51 @@ def parse_args(): default=None, help="Instruction for CustomVoice (emotion/style) or VoiceDesign (voice description)", ) + parser.add_argument( + "--quality", type=str, default=None, help="Quality hint for supported models" + ) + parser.add_argument( + "--sound_event", + type=str, + default=None, + help="Sound event description for supported models", + ) + parser.add_argument( + "--ambient_sound", + type=str, + default=None, + help="Ambient sound description for supported models", + ) + parser.add_argument( + "--language", + type=str, + default=None, + help="Language hint for supported models", + ) + parser.add_argument( + "--preset", + type=str, + default=None, + help=( + "Variant sampling preset (MOSS family): moss_tts, moss_tts_local, " + "ttsd, voice_generator, soundeffect, realtime" + ), + ) + parser.add_argument( + "--model_kwargs_json", + type=str, + default=None, + help="JSON object for advanced model.generate kwargs (escape hatch)", + ) + parser.add_argument( + "--dialogue_speakers_json", + type=str, + default=None, + help=( + "Path to TTSD speaker schema JSON " + "(list of {speaker_id, ref_audio, ref_text/text})" + ), + ) parser.add_argument( "--exaggeration", type=float, @@ -356,6 +533,13 @@ def parse_args(): ) parser.add_argument("--pitch", type=float, default=1.0, help="Pitch of the voice") parser.add_argument("--lang_code", type=str, default="en", help="Language code") + parser.add_argument( + "--input_type", + type=str, + default="text", + choices=["text", "pinyin", "ipa"], + help="Input representation for supported models", + ) parser.add_argument( "--output_path", type=str, default=None, help="Directory path for output files" ) @@ -386,6 +570,12 @@ def parse_args(): parser.add_argument( "--temperature", type=float, default=0.7, help="Temperature for the model" ) + parser.add_argument( + "--seed", + type=int, + default=None, + help="Optional random seed for reproducible sampling paths", + ) parser.add_argument("--top_p", type=float, default=0.9, help="Top-p for the model") parser.add_argument("--top_k", type=int, default=50, help="Top-k for the model") parser.add_argument( @@ -394,6 +584,15 @@ def parse_args(): default=1.1, help="Repetition penalty for the model", ) + parser.add_argument( + "--repetition_window", + type=int, + default=None, + help=( + "Realtime repetition-history window size; <=0 disables windowing " + "and applies repetition penalty over full history" + ), + ) parser.add_argument( "--stream", action="store_true", @@ -405,9 +604,59 @@ def parse_args(): default=2.0, help="The time interval in seconds for streaming segments", ) + parser.add_argument( + "--long_form", + action="store_true", + help="Enable segmented long-form generation for MOSS-TTS variants", + ) + parser.add_argument( + "--long_form_min_chars", + type=int, + default=160, + help="Minimum per-segment text budget for long-form planning", + ) + parser.add_argument( + "--long_form_target_chars", + type=int, + default=320, + help="Target per-segment text budget for long-form planning", + ) + parser.add_argument( + "--long_form_max_chars", + type=int, + default=520, + help="Maximum per-segment text budget for long-form planning", + ) + parser.add_argument( + "--long_form_prefix_audio_seconds", + type=float, + default=2.0, + help="Carry-forward tail duration (seconds) between long-form segments", + ) + parser.add_argument( + "--long_form_prefix_audio_max_tokens", + type=int, + default=25, + help="Carry-forward tail budget in audio tokens (stricter cap wins)", + ) + parser.add_argument( + "--long_form_prefix_text_chars", + type=int, + default=0, + help="Optional carry-forward text window size in characters", + ) + parser.add_argument( + "--long_form_retry_attempts", + type=int, + default=0, + help="Retry attempts per long-form segment before failing", + ) args = parser.parse_args() + if args.text is None and args.ambient_sound is not None: + args.text = args.ambient_sound + if args.text is None: if not sys.stdin.isatty(): args.text = sys.stdin.read().strip() diff --git a/mlx_audio/tts/tests/test_generate_stream_contracts.py b/mlx_audio/tts/tests/test_generate_stream_contracts.py new file mode 100644 index 000000000..353b7751b --- /dev/null +++ b/mlx_audio/tts/tests/test_generate_stream_contracts.py @@ -0,0 +1,266 @@ +import io +import os +import tempfile +import unittest +from contextlib import redirect_stderr, redirect_stdout +from types import SimpleNamespace +from unittest.mock import patch + +import mlx.core as mx + +from mlx_audio.tts.generate import generate_audio + + +class _DummyModel: + sample_rate = 24000 + + def __init__(self, results): + self._results = list(results) + self.generate_call_count = 0 + self.last_generate_kwargs = None + + def generate(self, **kwargs): + self.generate_call_count += 1 + self.last_generate_kwargs = dict(kwargs) + for result in self._results: + yield result + + +class _DummyPlayer: + def __init__(self, sample_rate): + self.sample_rate = sample_rate + self.queued = 0 + self.waited = False + self.stopped = False + + def queue_audio(self, _audio): + self.queued += 1 + + def wait_for_drain(self): + self.waited = True + + def stop(self): + self.stopped = True + + +def _result(audio_scale): + audio = mx.array([0.1 * audio_scale, 0.2 * audio_scale], dtype=mx.float32) + return SimpleNamespace( + audio=audio, + sample_rate=24000, + audio_duration="00:00:00.000", + token_count=4, + prompt={"tokens-per-sec": 42.0}, + audio_samples={"samples": int(audio.shape[0]), "samples-per-sec": 24000.0}, + real_time_factor=0.1, + processing_time_seconds=0.01, + peak_memory_usage=0.01, + ) + + +class TestGenerateStreamContracts(unittest.TestCase): + def test_stream_writes_chunks_with_output_path_and_no_playback(self): + model = _DummyModel([_result(1), _result(2)]) + + with ( + tempfile.TemporaryDirectory() as tmpdir, + patch("mlx_audio.tts.generate.audio_write") as audio_write_mock, + ): + generate_audio( + text="stream me", + model=model, + stream=True, + play=False, + output_path=tmpdir, + file_prefix="stream_case", + verbose=False, + ) + + self.assertEqual(model.generate_call_count, 1) + self.assertEqual(audio_write_mock.call_count, 2) + written_files = [call.args[0] for call in audio_write_mock.call_args_list] + self.assertEqual( + written_files, + [ + os.path.join(tmpdir, "stream_case_000.wav"), + os.path.join(tmpdir, "stream_case_001.wav"), + ], + ) + + def test_stream_without_sink_fails_before_generation(self): + model = _DummyModel([_result(1)]) + output = io.StringIO() + + with ( + patch("mlx_audio.tts.generate.audio_write") as audio_write_mock, + redirect_stdout(output), + redirect_stderr(output), + ): + generate_audio( + text="missing sink", + model=model, + stream=True, + play=False, + output_path=None, + verbose=False, + ) + + self.assertEqual(model.generate_call_count, 0) + audio_write_mock.assert_not_called() + self.assertIn( + "Streaming mode requires at least one sink", + output.getvalue(), + ) + + def test_stream_with_playback_allows_no_output_path(self): + model = _DummyModel([_result(1), _result(2)]) + created_players: list[_DummyPlayer] = [] + + def _make_player(sample_rate): + player = _DummyPlayer(sample_rate=sample_rate) + created_players.append(player) + return player + + with ( + patch("mlx_audio.tts.generate.AudioPlayer", side_effect=_make_player), + patch("mlx_audio.tts.generate.audio_write") as audio_write_mock, + ): + generate_audio( + text="playback sink", + model=model, + stream=True, + play=True, + output_path=None, + verbose=False, + ) + + self.assertEqual(model.generate_call_count, 1) + audio_write_mock.assert_not_called() + self.assertEqual(len(created_players), 1) + self.assertEqual(created_players[0].queued, 2) + self.assertTrue(created_players[0].waited) + self.assertTrue(created_players[0].stopped) + + def test_join_audio_verbose_does_not_reference_chunk_file_name(self): + model = _DummyModel([_result(1), _result(2)]) + output = io.StringIO() + + with ( + tempfile.TemporaryDirectory() as tmpdir, + patch("mlx_audio.tts.generate.audio_write") as audio_write_mock, + redirect_stdout(output), + redirect_stderr(output), + ): + generate_audio( + text="join case", + model=model, + stream=False, + join_audio=True, + output_path=tmpdir, + file_prefix="joined_case", + verbose=True, + ) + + self.assertEqual(model.generate_call_count, 1) + self.assertEqual(audio_write_mock.call_count, 1) + self.assertEqual( + audio_write_mock.call_args.args[0], + os.path.join(tmpdir, "joined_case.wav"), + ) + self.assertNotIn("Error loading model", output.getvalue()) + self.assertIn( + "✅ Audio successfully generated and saving as:", output.getvalue() + ) + + def test_generate_passes_duration_and_n_vq_controls(self): + model = _DummyModel([_result(1)]) + + with ( + tempfile.TemporaryDirectory() as tmpdir, + patch("mlx_audio.tts.generate.audio_write"), + ): + generate_audio( + text="controls", + model=model, + stream=False, + output_path=tmpdir, + file_prefix="controls_case", + duration_s=None, + seconds=3.0, + n_vq_for_inference=8, + repetition_window=64, + verbose=False, + ) + + self.assertEqual(model.generate_call_count, 1) + self.assertIsNotNone(model.last_generate_kwargs) + assert model.last_generate_kwargs is not None + self.assertEqual(model.last_generate_kwargs.get("duration_s"), 3.0) + self.assertEqual(model.last_generate_kwargs.get("n_vq_for_inference"), 8) + self.assertEqual(model.last_generate_kwargs.get("repetition_window"), 64) + + def test_generate_forwards_preset_and_model_kwargs_json(self): + model = _DummyModel([_result(1)]) + + with ( + tempfile.TemporaryDirectory() as tmpdir, + patch("mlx_audio.tts.generate.audio_write"), + ): + generate_audio( + text="preset controls", + model=model, + stream=False, + output_path=tmpdir, + file_prefix="preset_case", + preset="moss_tts_local", + model_kwargs_json='{"decode_chunk_duration": 0.25, "top_k": 17}', + verbose=False, + ) + + self.assertEqual(model.generate_call_count, 1) + assert model.last_generate_kwargs is not None + self.assertEqual(model.last_generate_kwargs.get("preset"), "moss_tts_local") + self.assertEqual(model.last_generate_kwargs.get("decode_chunk_duration"), 0.25) + self.assertEqual(model.last_generate_kwargs.get("top_k"), 17) + + def test_long_form_controls_forward_only_when_enabled(self): + model = _DummyModel([_result(1)]) + + with ( + tempfile.TemporaryDirectory() as tmpdir, + patch("mlx_audio.tts.generate.audio_write"), + ): + generate_audio( + text="no long form", + model=model, + stream=False, + output_path=tmpdir, + file_prefix="no_long_form_case", + verbose=False, + ) + assert model.last_generate_kwargs is not None + self.assertNotIn("long_form", model.last_generate_kwargs) + + with ( + tempfile.TemporaryDirectory() as tmpdir, + patch("mlx_audio.tts.generate.audio_write"), + ): + generate_audio( + text="with long form", + model=model, + stream=False, + output_path=tmpdir, + file_prefix="long_form_case", + long_form=True, + long_form_min_chars=111, + long_form_retry_attempts=2, + verbose=False, + ) + assert model.last_generate_kwargs is not None + self.assertTrue(model.last_generate_kwargs.get("long_form")) + self.assertEqual(model.last_generate_kwargs.get("long_form_min_chars"), 111) + self.assertEqual(model.last_generate_kwargs.get("long_form_retry_attempts"), 2) + + +if __name__ == "__main__": + unittest.main() From 1082c949afee8c29296b0602c268ed42a9037cdc Mon Sep 17 00:00:00 2001 From: Jeff Coggshall Date: Sun, 15 Feb 2026 16:29:21 -0800 Subject: [PATCH 3/5] tts gateway: route tasks via generate/generate_stream contracts --- mlx_audio/tts/generate.py | 218 +++++++++++++++--- .../tests/test_generate_stream_contracts.py | 178 +++++++++++++- 2 files changed, 368 insertions(+), 28 deletions(-) diff --git a/mlx_audio/tts/generate.py b/mlx_audio/tts/generate.py index 6d53833e1..36080e97f 100644 --- a/mlx_audio/tts/generate.py +++ b/mlx_audio/tts/generate.py @@ -15,6 +15,177 @@ from .audio_player import AudioPlayer from .utils import load_model +_MOSS_PRESET_ALIASES = { + "voicegenerator": "voice_generator", + "moss_voice_generator": "voice_generator", + "moss_voicegenerator": "voice_generator", + "moss_voice_design": "voice_generator", + "moss_sound_effect": "soundeffect", + "sound_effect": "soundeffect", + "moss_soundeffect": "soundeffect", +} + +_MOSS_PRESET_TASKS = { + "moss_tts": "default_tts", + "moss_tts_local": "default_tts", + "ttsd": "ttsd", + "soundeffect": "soundeffect", + "voice_generator": "voice_generator", + "realtime": "realtime", +} + +_TASK_DEFAULT_PRESETS = { + "ttsd": "ttsd", + "soundeffect": "soundeffect", + "voice_generator": "voice_generator", + "realtime": "realtime", +} + +_REALTIME_ONLY_KWARGS = frozenset( + { + "chunk_frames", + "overlap_frames", + "decode_chunk_duration", + "max_pending_frames", + "repetition_window", + } +) + + +def _normalize_preset(preset: Optional[str]) -> Optional[str]: + if preset is None: + return None + normalized = str(preset).strip().lower().replace("-", "_") + return _MOSS_PRESET_ALIASES.get(normalized, normalized) + + +def _normalize_model_type(model_type: Optional[str]) -> Optional[str]: + if model_type is None: + return None + return str(model_type).strip().lower().replace("-", "_") + + +def _looks_like_moss_model(model: Optional[Union[str, nn.Module]]) -> bool: + if isinstance(model, str): + return "moss" in model.lower() + if model is None: + return False + model_type = _normalize_model_type(getattr(model, "model_type", None)) + if model_type: + return "moss" in model_type + return "moss" in str(type(model)).lower() + + +def _is_explicit_realtime_model(model: Optional[Union[str, nn.Module]]) -> bool: + if isinstance(model, str): + normalized = model.strip().lower().replace("-", "_") + return "moss_tts_realtime" in normalized or "moss/tts/realtime" in normalized + if model is None: + return False + model_type = _normalize_model_type(getattr(model, "model_type", None)) + return model_type == "moss_tts_realtime" + + +def _parse_model_kwargs_json( + model_kwargs_json: Optional[Union[str, dict[str, Any]]], +) -> dict[str, Any]: + if model_kwargs_json is None: + return {} + parsed_kwargs = model_kwargs_json + if isinstance(parsed_kwargs, str): + try: + parsed_kwargs = json.loads(parsed_kwargs) + except json.JSONDecodeError as exc: + raise ValueError(f"Invalid --model_kwargs_json value: {exc.msg}") from exc + if not isinstance(parsed_kwargs, dict): + raise ValueError("--model_kwargs_json must decode to a JSON object") + return dict(parsed_kwargs) + + +def _load_dialogue_speakers( + dialogue_speakers_json: Optional[str], +) -> Optional[list[dict[str, Any]]]: + if dialogue_speakers_json is None: + return None + with open(dialogue_speakers_json, "r", encoding="utf-8") as f: + raw = json.load(f) + if not isinstance(raw, list): + raise ValueError("dialogue_speakers_json must contain a JSON list") + dialogue_speakers: list[dict[str, Any]] = [] + for item in raw: + if not isinstance(item, dict): + raise ValueError("Each dialogue_speakers item must be a JSON object") + dialogue_speakers.append(item) + return dialogue_speakers + + +def _infer_gateway_task_and_preset( + *, + model: Optional[Union[str, nn.Module]], + preset: Optional[str], + dialogue_speakers: Optional[list[dict[str, Any]]], + sound_event: Optional[str], + ambient_sound: Optional[str], + instruct: Optional[str], + repetition_window: Optional[int], + passthrough_kwargs: dict[str, Any], +) -> tuple[str, Optional[str]]: + normalized_preset = _normalize_preset(preset) + explicit_preset_task = _MOSS_PRESET_TASKS.get(normalized_preset) + + explicit_realtime_marker = _is_explicit_realtime_model(model) + realtime_kwarg_marker = bool(_REALTIME_ONLY_KWARGS & set(passthrough_kwargs.keys())) + moss_hint = ( + _looks_like_moss_model(model) + or normalized_preset in _MOSS_PRESET_TASKS + or bool(dialogue_speakers) + or bool(ambient_sound or sound_event) + ) + realtime_marker = ( + explicit_realtime_marker + or normalized_preset == "realtime" + or (moss_hint and (repetition_window is not None or realtime_kwarg_marker)) + ) + + ttsd_marker = bool(dialogue_speakers) or normalized_preset == "ttsd" + soundeffect_marker = bool(ambient_sound or sound_event) or ( + normalized_preset == "soundeffect" + ) + + moss_context = moss_hint or ttsd_marker or soundeffect_marker or realtime_marker + has_instruct = bool(str(instruct).strip()) if instruct is not None else False + voice_design_marker = normalized_preset == "voice_generator" or ( + moss_context and has_instruct + ) + + inferred_task = "default_tts" + if realtime_marker: + inferred_task = "realtime" + elif ttsd_marker: + inferred_task = "ttsd" + elif soundeffect_marker: + inferred_task = "soundeffect" + elif voice_design_marker: + inferred_task = "voice_generator" + + if ( + explicit_preset_task is not None + and inferred_task != explicit_preset_task + and inferred_task != "default_tts" + ): + raise ValueError( + "Incompatible task markers: " + f"preset={preset!r} conflicts with inferred task '{inferred_task}'" + ) + + effective_preset = preset + if normalized_preset in _MOSS_PRESET_TASKS: + effective_preset = normalized_preset + elif inferred_task in _TASK_DEFAULT_PRESETS: + effective_preset = _TASK_DEFAULT_PRESETS[inferred_task] + + return inferred_task, effective_preset + def detect_speech_boundaries( wav: np.ndarray, @@ -242,23 +413,22 @@ def generate_audio( "Streaming mode requires at least one sink: enable --play or provide --output_path." ) + parsed_model_kwargs_json = _parse_model_kwargs_json(model_kwargs_json) + dialogue_speakers = _load_dialogue_speakers(dialogue_speakers_json) + _, effective_preset = _infer_gateway_task_and_preset( + model=model, + preset=preset, + dialogue_speakers=dialogue_speakers, + sound_event=sound_event, + ambient_sound=ambient_sound, + instruct=instruct, + repetition_window=repetition_window, + passthrough_kwargs=kwargs, + ) + if instruct is not None: print(f"\033[94mInstruct:\033[0m {instruct}") - dialogue_speakers: Optional[list[dict[str, Any]]] = None - if dialogue_speakers_json is not None: - with open(dialogue_speakers_json, "r", encoding="utf-8") as f: - raw = json.load(f) - if not isinstance(raw, list): - raise ValueError("dialogue_speakers_json must contain a JSON list") - dialogue_speakers = [] - for item in raw: - if not isinstance(item, dict): - raise ValueError( - "Each dialogue_speakers item must be a JSON object" - ) - dialogue_speakers.append(item) - print( f"\033[94mText:\033[0m {text}\n" f"\033[94mVoice:\033[0m {voice}\n" @@ -281,7 +451,7 @@ def generate_audio( sound_event=sound_event, ambient_sound=ambient_sound, language=language, - preset=preset, + preset=effective_preset, n_vq_for_inference=n_vq_for_inference, dialogue_speakers=dialogue_speakers, input_type=input_type, @@ -294,18 +464,7 @@ def generate_audio( instruct=instruct, **kwargs, ) - if model_kwargs_json is not None: - parsed_kwargs = model_kwargs_json - if isinstance(parsed_kwargs, str): - try: - parsed_kwargs = json.loads(parsed_kwargs) - except json.JSONDecodeError as exc: - raise ValueError( - f"Invalid --model_kwargs_json value: {exc.msg}" - ) from exc - if not isinstance(parsed_kwargs, dict): - raise ValueError("--model_kwargs_json must decode to a JSON object") - gen_kwargs.update(parsed_kwargs) + gen_kwargs.update(parsed_model_kwargs_json) if long_form: gen_kwargs.update( @@ -405,6 +564,11 @@ def generate_audio( traceback.print_exc() +def generate_stream(text: Optional[str], **kwargs) -> None: + kwargs["stream"] = True + generate_audio(text=text, **kwargs) + + def parse_args(): parser = argparse.ArgumentParser(description="Generate audio from text using TTS.") parser.add_argument( diff --git a/mlx_audio/tts/tests/test_generate_stream_contracts.py b/mlx_audio/tts/tests/test_generate_stream_contracts.py index 353b7751b..e8304565e 100644 --- a/mlx_audio/tts/tests/test_generate_stream_contracts.py +++ b/mlx_audio/tts/tests/test_generate_stream_contracts.py @@ -1,4 +1,5 @@ import io +import json import os import tempfile import unittest @@ -8,7 +9,7 @@ import mlx.core as mx -from mlx_audio.tts.generate import generate_audio +from mlx_audio.tts.generate import generate_audio, generate_stream class _DummyModel: @@ -261,6 +262,181 @@ def test_long_form_controls_forward_only_when_enabled(self): self.assertEqual(model.last_generate_kwargs.get("long_form_min_chars"), 111) self.assertEqual(model.last_generate_kwargs.get("long_form_retry_attempts"), 2) + def test_gateway_infers_ttsd_when_dialogue_payload_is_present(self): + model = _DummyModel([_result(1)]) + dialogue_payload = [ + {"speaker_id": 1, "ref_audio": "spk1.wav", "ref_text": "hello"}, + {"speaker_id": 2, "ref_audio": "spk2.wav", "ref_text": "world"}, + ] + + with ( + tempfile.TemporaryDirectory() as tmpdir, + patch("mlx_audio.tts.generate.audio_write"), + ): + dialogue_json = os.path.join(tmpdir, "dialogue.json") + with open(dialogue_json, "w", encoding="utf-8") as f: + json.dump(dialogue_payload, f) + + generate_audio( + text="dialogue route", + model=model, + output_path=tmpdir, + file_prefix="dialogue_case", + dialogue_speakers_json=dialogue_json, + verbose=False, + ) + + self.assertEqual(model.generate_call_count, 1) + assert model.last_generate_kwargs is not None + self.assertEqual(model.last_generate_kwargs.get("preset"), "ttsd") + self.assertEqual( + model.last_generate_kwargs.get("dialogue_speakers"), dialogue_payload + ) + + def test_gateway_routing_precedence_realtime_over_ttsd_and_soundeffect(self): + model = _DummyModel([_result(1)]) + dialogue_payload = [{"speaker_id": 1, "ref_audio": "spk1.wav", "ref_text": "x"}] + + with ( + tempfile.TemporaryDirectory() as tmpdir, + patch("mlx_audio.tts.generate.audio_write"), + patch("mlx_audio.tts.generate.load_model", return_value=model), + ): + dialogue_json = os.path.join(tmpdir, "dialogue.json") + with open(dialogue_json, "w", encoding="utf-8") as f: + json.dump(dialogue_payload, f) + + generate_audio( + text="priority route", + model="OpenMOSS-Team/MOSS-TTS", + output_path=tmpdir, + file_prefix="priority_case", + dialogue_speakers_json=dialogue_json, + sound_event="wind", + repetition_window=50, + verbose=False, + ) + + self.assertEqual(model.generate_call_count, 1) + assert model.last_generate_kwargs is not None + self.assertEqual(model.last_generate_kwargs.get("preset"), "realtime") + + def test_gateway_routing_precedence_ttsd_over_soundeffect(self): + model = _DummyModel([_result(1)]) + + with ( + tempfile.TemporaryDirectory() as tmpdir, + patch("mlx_audio.tts.generate.audio_write"), + ): + dialogue_json = os.path.join(tmpdir, "dialogue.json") + with open(dialogue_json, "w", encoding="utf-8") as f: + json.dump([{"speaker_id": 1, "ref_audio": "x.wav", "ref_text": "x"}], f) + + generate_audio( + text="dialogue beats sound", + model=model, + output_path=tmpdir, + file_prefix="ttsd_precedence_case", + dialogue_speakers_json=dialogue_json, + sound_event="thunder", + verbose=False, + ) + + self.assertEqual(model.generate_call_count, 1) + assert model.last_generate_kwargs is not None + self.assertEqual(model.last_generate_kwargs.get("preset"), "ttsd") + + def test_gateway_routing_precedence_soundeffect_over_voice_design(self): + model = _DummyModel([_result(1)]) + model.model_type = "moss_tts" + + with ( + tempfile.TemporaryDirectory() as tmpdir, + patch("mlx_audio.tts.generate.audio_write"), + ): + generate_audio( + text=None, + model=model, + output_path=tmpdir, + file_prefix="sound_precedence_case", + sound_event="city ambience", + instruct="warm and energetic", + verbose=False, + ) + + self.assertEqual(model.generate_call_count, 1) + assert model.last_generate_kwargs is not None + self.assertEqual(model.last_generate_kwargs.get("preset"), "soundeffect") + + def test_gateway_infers_voice_generator_from_instruct_in_moss_context(self): + model = _DummyModel([_result(1)]) + model.model_type = "moss_tts" + + with ( + tempfile.TemporaryDirectory() as tmpdir, + patch("mlx_audio.tts.generate.audio_write"), + patch("mlx_audio.tts.generate.load_model", return_value=model), + ): + generate_audio( + text="voice design route", + model="OpenMOSS-Team/MOSS-TTS", + output_path=tmpdir, + file_prefix="voice_design_case", + instruct="Warm, cinematic narrator", + verbose=False, + ) + + self.assertEqual(model.generate_call_count, 1) + assert model.last_generate_kwargs is not None + self.assertEqual(model.last_generate_kwargs.get("preset"), "voice_generator") + + def test_gateway_rejects_conflicting_markers(self): + model = _DummyModel([_result(1)]) + output = io.StringIO() + + with ( + tempfile.TemporaryDirectory() as tmpdir, + patch("mlx_audio.tts.generate.audio_write"), + redirect_stdout(output), + redirect_stderr(output), + ): + dialogue_json = os.path.join(tmpdir, "dialogue.json") + with open(dialogue_json, "w", encoding="utf-8") as f: + json.dump([{"speaker_id": 1, "ref_audio": "x.wav", "ref_text": "x"}], f) + + generate_audio( + text="conflict route", + model=model, + output_path=tmpdir, + file_prefix="conflict_case", + preset="voice_generator", + dialogue_speakers_json=dialogue_json, + verbose=False, + ) + + self.assertEqual(model.generate_call_count, 0) + self.assertIn("Incompatible task markers", output.getvalue()) + + def test_generate_stream_wrapper_forces_stream_true(self): + model = _DummyModel([_result(1)]) + + with ( + tempfile.TemporaryDirectory() as tmpdir, + patch("mlx_audio.tts.generate.audio_write") as audio_write_mock, + ): + generate_stream( + text="stream wrapper", + model=model, + output_path=tmpdir, + file_prefix="stream_wrapper_case", + verbose=False, + ) + + self.assertEqual(model.generate_call_count, 1) + self.assertEqual(audio_write_mock.call_count, 1) + assert model.last_generate_kwargs is not None + self.assertTrue(model.last_generate_kwargs.get("stream")) + if __name__ == "__main__": unittest.main() From ceebb919534d5b53c055c8834dda3b985ff9f71b Mon Sep 17 00:00:00 2001 From: Jeff Coggshall Date: Sun, 15 Feb 2026 16:33:30 -0800 Subject: [PATCH 4/5] moss core tts runtime: delay/local + long-form + pronunciation --- mlx_audio/tts/models/moss_tts/README.md | 320 +++++ .../tts/models/moss_tts/TECHNICAL_OVERVIEW.md | 208 +++ mlx_audio/tts/models/moss_tts/__init__.py | 44 + mlx_audio/tts/models/moss_tts/audit.py | 161 +++ mlx_audio/tts/models/moss_tts/backbone.py | 184 +++ mlx_audio/tts/models/moss_tts/config.py | 147 ++ mlx_audio/tts/models/moss_tts/delay_model.py | 110 ++ .../tts/models/moss_tts/inference_utils.py | 193 +++ mlx_audio/tts/models/moss_tts/local_model.py | 206 +++ .../tts/models/moss_tts/local_transformer.py | 151 ++ mlx_audio/tts/models/moss_tts/long_form.py | 484 +++++++ mlx_audio/tts/models/moss_tts/model.py | 1213 +++++++++++++++++ mlx_audio/tts/models/moss_tts/presets.py | 126 ++ mlx_audio/tts/models/moss_tts/processor.py | 910 +++++++++++++ .../tts/models/moss_tts/pronunciation.py | 309 +++++ mlx_audio/tts/models/moss_tts/request.py | 97 ++ mlx_audio/tts/models/moss_tts/sampling.py | 132 ++ .../tests/test_moss_tts_audit_contracts.py | 96 ++ .../tests/test_moss_tts_bootstrap_safety.py | 47 + .../tts/tests/test_moss_tts_local_runtime.py | 532 ++++++++ .../tests/test_moss_tts_long_form_runtime.py | 485 +++++++ .../test_moss_tts_pronunciation_contracts.py | 208 +++ 22 files changed, 6363 insertions(+) create mode 100644 mlx_audio/tts/models/moss_tts/README.md create mode 100644 mlx_audio/tts/models/moss_tts/TECHNICAL_OVERVIEW.md create mode 100644 mlx_audio/tts/models/moss_tts/__init__.py create mode 100644 mlx_audio/tts/models/moss_tts/audit.py create mode 100644 mlx_audio/tts/models/moss_tts/backbone.py create mode 100644 mlx_audio/tts/models/moss_tts/config.py create mode 100644 mlx_audio/tts/models/moss_tts/delay_model.py create mode 100644 mlx_audio/tts/models/moss_tts/inference_utils.py create mode 100644 mlx_audio/tts/models/moss_tts/local_model.py create mode 100644 mlx_audio/tts/models/moss_tts/local_transformer.py create mode 100644 mlx_audio/tts/models/moss_tts/long_form.py create mode 100644 mlx_audio/tts/models/moss_tts/model.py create mode 100644 mlx_audio/tts/models/moss_tts/presets.py create mode 100644 mlx_audio/tts/models/moss_tts/processor.py create mode 100644 mlx_audio/tts/models/moss_tts/pronunciation.py create mode 100644 mlx_audio/tts/models/moss_tts/request.py create mode 100644 mlx_audio/tts/models/moss_tts/sampling.py create mode 100644 mlx_audio/tts/tests/test_moss_tts_audit_contracts.py create mode 100644 mlx_audio/tts/tests/test_moss_tts_bootstrap_safety.py create mode 100644 mlx_audio/tts/tests/test_moss_tts_local_runtime.py create mode 100644 mlx_audio/tts/tests/test_moss_tts_long_form_runtime.py create mode 100644 mlx_audio/tts/tests/test_moss_tts_pronunciation_contracts.py diff --git a/mlx_audio/tts/models/moss_tts/README.md b/mlx_audio/tts/models/moss_tts/README.md new file mode 100644 index 000000000..84b31a160 --- /dev/null +++ b/mlx_audio/tts/models/moss_tts/README.md @@ -0,0 +1,320 @@ +# MOSS-TTS Family (MLX Runtime) + +Unified MLX runtime support for the OpenMOSS MOSS-TTS family. + +Supported checkpoints: + +- `OpenMOSS-Team/MOSS-TTS` (Delay) +- `OpenMOSS-Team/MOSS-TTS-Local-Transformer` (Local) +- `OpenMOSS-Team/MOSS-TTSD-v1.0` (TTSD) +- `OpenMOSS-Team/MOSS-Voice-Generator` (VoiceGenerator) +- `OpenMOSS-Team/MOSS-SoundEffect` (SoundEffect) + +Realtime has a dedicated runtime package: `mlx_audio/tts/models/moss_tts_realtime/`. + +## Model Variants + +| Variant | HF ID | Runtime | Preset | Primary Use | +|---|---|---|---|---| +| Delay | `OpenMOSS-Team/MOSS-TTS` | `moss_tts` | `moss_tts` | General high-capability TTS | +| Local | `OpenMOSS-Team/MOSS-TTS-Local-Transformer` | `moss_tts` | `moss_tts_local` | Lower-memory TTS, inference depth override | +| TTSD | `OpenMOSS-Team/MOSS-TTSD-v1.0` | `moss_tts` | `ttsd` | Multi-speaker dialogue | +| VoiceGenerator | `OpenMOSS-Team/MOSS-Voice-Generator` | `moss_tts` | `voice_generator` | Voice design from text instruction | +| SoundEffect | `OpenMOSS-Team/MOSS-SoundEffect` | `moss_tts` | `soundeffect` | Text-to-sound-event synthesis | + +## Quick Start + +### Python API + +```python +from mlx_audio.tts.utils import load_model + +model = load_model("OpenMOSS-Team/MOSS-TTS-Local-Transformer") + +results = list( + model.generate( + text="Hello from MOSS on MLX.", + preset="moss_tts_local", + input_type="text", # text | pinyin | ipa + duration_s=6.0, # mapped to tokens at 12.5 Hz when tokens is omitted + max_tokens=240, + ) +) + +audio = results[0].audio +``` + +### CLI + +```bash +uv run python -m mlx_audio.tts.generate \ + --model OpenMOSS-Team/MOSS-TTS-Local-Transformer \ + --text "Hello from MOSS on MLX." \ + --preset moss_tts_local \ + --duration_s 6 \ + --output_path ./outputs/moss_local +``` + +More complete runnable examples are in: + +- `examples/moss_tts_basic.py` +- `examples/moss_tts_deterministic.py` +- `examples/moss_tts_voice_cloning.py` +- `examples/moss_tts_continuation_showcase.py` +- `examples/moss_ttsd_dialogue.py` +- `examples/moss_voice_design.py` +- `examples/moss_sound_effects.py` +- `examples/moss_tts_long_form.py` +- `examples/moss_tts_pronunciation_control.py` +- `examples/moss_tts_realtime_text_deltas.py` +- `examples/moss_tts_realtime_multiturn_agent.py` +- `examples/moss_tts_showcase_album.py` + +## Showcase Recipes + +Use these scripts as fast entry points for the most important MOSS workflows: + +- Continuation prompting (assistant prefix audio, no `ref_audio` generate arg): + +```bash +uv run python examples/moss_tts_continuation_showcase.py \ + --model OpenMOSS-Team/MOSS-TTS-Local-Transformer \ + --preset moss_tts_local +``` + +- Realtime multiturn agent flow (persistent voice prompt + per-turn user audio): + +```bash +uv run python examples/moss_tts_realtime_multiturn_agent.py \ + --model OpenMOSS-Team/MOSS-TTS-Realtime \ + --save-chunks +``` + +- Full family album export (all variants + shareable manifest files): + +```bash +uv run python examples/moss_tts_showcase_album.py \ + --output-dir outputs/moss_tts_showcase_album +``` + +`moss_tts_showcase_album.py` writes `showcase_album.json` and +`showcase_album.md` alongside generated tracks. + +## Deterministic Recipes + +If voice/style/emotion vary between runs, use the deterministic script: +`examples/moss_tts_deterministic.py`. + +Default mode is deterministic **hybrid** decoding: + +- text/control channel: greedy (`do_sample=False`) +- audio channels: seeded sampling (`do_sample=True`) + +This remains reproducible but avoids a known full-greedy silence collapse on +some prompts/checkpoints. + +- Deterministic text-only generation: + +```bash +uv run python examples/moss_tts_deterministic.py \ + --model OpenMOSS-Team/MOSS-TTS-Local-Transformer \ + --preset moss_tts_local \ + --text "Hello what is happening this is from MOSS on MLX." \ + --instruct "Calm, friendly, medium speaking rate, conversational tone." \ + --output-dir outputs/moss_tts_deterministic +``` + +- Direct CLI equivalent (`-m mlx_audio.tts.generate`) using `model_kwargs_json`: + +```bash +uv run python -m mlx_audio.tts.generate \ + --model OpenMOSS-Team/MOSS-TTS-Local-Transformer \ + --preset moss_tts_local \ + --text "Hello what is happening this is from MOSS on MLX." \ + --instruct "Calm, friendly, medium speaking rate, conversational tone." \ + --seed 1234 \ + --model_kwargs_json '{"do_samples":[false]}' \ + --output_path outputs/moss_tts_deterministic_cli +``` + +- Deterministic reference-conditioned generation (strongest voice consistency): + +```bash +uv run python examples/moss_tts_deterministic.py \ + --model OpenMOSS-Team/MOSS-TTS-Local-Transformer \ + --preset moss_tts_local \ + --with-reference \ + --ref-audio REFERENCE/MOSS-Audio-Tokenizer/demo/demo_gt.wav \ + --ref-text "Demo reference transcript." \ + --text "Hello what is happening this is from MOSS on MLX." \ + --instruct "Warm expressive narrator, medium speaking rate." \ + --output-dir outputs/moss_tts_deterministic_ref +``` + +Notes: + +- `preset` sets default sampling (`temperature`, `top_p`, `top_k`, `repetition_penalty`). +- Determinism in this recipe comes from fixed seed + fixed channel sampling flags. +- `--determinism-mode full_greedy` is available, but can produce silent clips in some cases. +- For short prompts without references, avoid forcing a long `duration_s`; let natural stopping decide clip length. +- Keep `text`, `instruct`, `duration_s`/`tokens`, and reference inputs identical across runs for reproducibility. + +## Generation Controls + +### Common fields + +| Field | Meaning | +|---|---| +| `text` | Primary user text | +| `ref_audio`, `ref_text` | Voice/reference conditioning | +| `instruct` | Style or voice instruction | +| `tokens` | Explicit target token budget | +| `duration_s` / `seconds` | Convenience duration mapped at 12.5 tokens/sec | +| `quality` | Quality hint string (variant-dependent behavior) | +| `input_type` | `text`, `pinyin`, or `ipa` | +| `preset` | Variant sampling defaults | +| `stream`, `streaming_interval` | Chunked output controls | +| `repetition_window` | Realtime-only repetition-penalty history window | + +### `quality` hint (advisory) + +This integration treats `quality` as a user hint string. Recommended values: + +- `draft`, `balanced`, `high`, `max`, or `custom: