Explorar el Código

Added additional engine

Efren Yevale Varela hace 1 mes
padre
commit
e254cc310d

+ 9 - 0
README.md

@@ -111,6 +111,7 @@ rms_floor        = 0.008              # Drop segments quieter than this RMS (noi
 trailing_trim_ms = 200                # Keep only this much trailing silence (less hallucination-prone padding)
 
 [whisper]
+backend                 = "faster-whisper"     # "faster-whisper" (CTranslate2) | "pywhispercpp" (whisper.cpp; see docs/PYWHISPERCPP.md)
 model                   = "small"               # "tiny"/"base"/"small"/"medium"/"large" (multilingual; do NOT use `.en` variants)
 device                  = "cpu"                 # "cpu" or "cuda"
 compute_type            = "int8"                # "int8" (CPU) or "float16" (CUDA, slower but more accurate)
@@ -187,6 +188,14 @@ system_prompt_chat       = "You are a concise voice assistant. Reply in 1-3 sent
 system_prompt_command    = "You translate a spoken request into a single tool call when one applies."
 ```
 
+`backend` picks the transcription engine. `faster-whisper` is the default, so nothing changes
+unless you opt in. `pywhispercpp` selects the whisper.cpp backend (GPU via the vendored CUDA
+wheel; see [docs/PYWHISPERCPP.md](docs/PYWHISPERCPP.md)). `model`, `language`, `beam_size`,
+`cpu_threads`, `no_speech_prob_max`, `avg_logprob_min`, `language_strategy`, and
+`sticky_redetect_logprob` apply to both; `device` and `compute_type` apply only to
+`faster-whisper`; `vad_filter` is ignored (with a warning) on `pywhispercpp`. For `pywhispercpp`
+the ggml quantization is chosen by the model name (e.g. `small`, `small-q5_1`).
+
 `max_prompt_tokens` was removed: the prompt budget is derived as
 `max_context_tokens - max_tokens - compaction_safety_margin`, so the window and the trim budget
 can never disagree. A legacy `max_prompt_tokens` key is ignored with a warning.

+ 1 - 0
config.example.toml

@@ -19,6 +19,7 @@ rms_floor        = 0.008
 trailing_trim_ms = 200
 
 [whisper]
+backend                 = "faster-whisper"
 model                   = "small"
 device                  = "cpu"
 compute_type            = "int8"

+ 181 - 0
docs/PYWHISPERCPP.md

@@ -0,0 +1,181 @@
+# pywhispercpp: GPU source rebuild
+
+`pywhispercpp` is a Python binding for `whisper.cpp` (the ggml ASR engine, same
+family as `llama.cpp`). It is an alternative transcription backend to
+`faster-whisper`/CTranslate2, which on this machine cannot use the GPU (see
+"Background" below).
+
+## Background
+
+- **Stock PyPI wheel:** `pywhispercpp 1.5.1` bundles `libwhisper.so.1.8.5` +
+  ggml 0.13.1 with the **CPU backend only** (`libggml-cpu`; no
+  `libggml-vulkan`, no `libggml-cuda`). The model-load log prints
+  `whisper_backend_init_gpu: no GPU found`.
+- **It works, on CPU:** the JFK sample transcribes correctly with `ggml-small`,
+  at RTF ≈ 0.18 (11 s of audio in ≈ 2.0 s).
+- **To use the GPU you must rebuild from source** with `GGML_VULKAN=1` or
+  `GGML_CUDA=1`. The PyPI wheel cannot be reconfigured.
+
+`faster-whisper`/CTranslate2 is hard-locked to CUDA 12 + cuDNN 9, and the CT2
+PyPI wheels lack sm_100/sm_120 (Blackwell) kernels. The CUDA 13 toolkit and the
+Vulkan stack are already installed, so a `whisper.cpp` build is the practical GPU
+path.
+
+## Verified result (this machine)
+
+CUDA 13 build, cloned to `~/Repositories/pywhispercpp`, built into a vendored
+wheel and installed into the project `.venv`. Runtime log:
+
+```
+whisper_init_with_params_no_state: use gpu    = 1
+ggml_cuda_init: found 1 CUDA devices (Total VRAM: 15888 MiB):
+  Device 0: NVIDIA RTX PRO 2000 Blackwell, compute capability 12.0
+whisper_model_load:        CUDA0 total size =   487.01 MB
+whisper_backend_init_gpu: using CUDA0 backend
+```
+
+JFK sample with `ggml-small`:
+
+| backend       | transcribe (11 s audio) | RTF  |
+| ------------- | ----------------------- | ---- |
+| CPU wheel     | 2.00 s                  | 0.18 |
+| CUDA 13 build | 0.15 s                  | 0.01 |
+
+≈13× faster, transcript correct. sm_120 (Blackwell) kernels are present and run.
+
+## Vendored wheel (locked into uv)
+
+The GPU build is kept as a local wheel so `uv sync` restores it instead of
+pruning it.
+
+- **Wheel:** `vendor/pywhispercpp-1.5.1-cp313-cp313-linux_x86_64.whl` (~136 MB).
+  `vendor/.gitignore` contains `*`, so it is **not committed** — it is a local
+  artifact. If it is deleted, `uv sync` fails; rebuild it (step 2).
+- **`pyproject.toml`:** the dependency is gated to the wheel's ABI, and the
+  source is the local file:
+
+  ```toml
+  dependencies = [
+      # ...
+      "pywhispercpp>=1.5.1 ; python_full_version == '3.13.*'",
+  ]
+
+  [tool.uv.sources]
+  pywhispercpp = { path = "vendor/pywhispercpp-1.5.1-cp313-cp313-linux_x86_64.whl" }
+  ```
+
+  The marker is required: the project supports Python `>=3.11`, but the wheel is
+  `cp313`-only, so an unconditional dependency fails universal resolution. On
+  Python 3.11/3.12 the package is simply not installed.
+- **`uv.lock`** records `pywhispercpp` with `source = { path = "vendor/..." }` and
+  its hash, so `uv sync` reinstalls the CUDA build. `uv sync --dry-run` reports
+  "Would make no changes".
+
+## Prerequisites (this machine)
+
+- Debian 13 (trixie), `git`, `gcc`/`g++` 14.2
+- **Vulkan:** `libvulkan-dev`, `glslc`, `glslang-tools`, `vulkan-tools`
+- **CUDA 13.3** toolkit at `/usr/local/cuda-13.3` (`nvcc 13.3.73`, cuBLAS 13.6)
+
+## 1. Source
+
+The clone already exists at `~/Repositories/pywhispercpp` (recursive clone;
+`pybind11` and `whisper.cpp` are submodules). If re-cloning, use
+`git clone --recursive` with **full history** (the build uses `setuptools-scm`,
+which needs git tags).
+
+## 2. Build the CUDA wheel
+
+Run from the clone, and build with the **project venv's Python** (the wheel must
+be `cp313`, matching the venv):
+
+```bash
+cd ~/Repositories/pywhispercpp
+CUDA_HOME=/usr/local/cuda-13.3 \
+CUDACXX=/usr/local/cuda-13.3/bin/nvcc \
+PATH="/usr/local/cuda-13.3/bin:$PATH" \
+PYWHISPERCPP_VERSION=1.5.1 \
+SETUPTOOLS_SCM_PRETEND_VERSION=1.5.1 \
+GGML_CUDA=1 \
+CMAKE_INSTALL_RPATH='$ORIGIN' \
+CMAKE_BUILD_WITH_INSTALL_RPATH=ON \
+uv build --wheel \
+  --python /home/efren/Repositories/STT-TTS/.venv/bin/python \
+  --out-dir /home/efren/Repositories/STT-TTS/vendor
+```
+
+- `GGML_CUDA=1` selects the CUDA backend; `setup.py` forwards **every** env var
+  to CMake as `-DKEY=VALUE`, so `CMAKE_INSTALL_RPATH` /
+  `CMAKE_BUILD_WITH_INSTALL_RPATH` can be set as plain env vars (no `CMAKE_ARGS`
+  needed).
+- `CMAKE_INSTALL_RPATH='$ORIGIN'` makes the wheel self-contained: the ggml libs
+  resolve from the package directory, not the clone's `build/` tree.
+- `--python <venv>/bin/python` is essential; without it the build uses whatever
+  Python it finds and produces the wrong ABI tag (e.g. `cp312`).
+
+Then install it through uv (already done; re-run only if the wheel changed):
+
+```bash
+cd /home/efren/Repositories/STT-TTS
+uv lock && uv sync
+```
+
+## 3. Verify the GPU is used
+
+```bash
+# Self-contained: RUNPATH should be $ORIGIN, libs under site-packages
+readelf -d .venv/lib/python3.13/site-packages/_pywhispercpp*.so | grep -i runpath
+ldd .venv/lib/python3.13/site-packages/_pywhispercpp*.so | grep -iE 'ggml|cublas'
+
+# Runtime init log (whisper.cpp logs to stderr by default)
+uv run python -c "from pywhispercpp.model import Model; Model('small')"
+```
+
+Success: `ggml_cuda_init: found 1 CUDA devices` / `using CUDA0 backend`, and not
+the `no GPU found` line. Note `redirect_whispercpp_logs_to` accepts `None`, a
+filepath string, or a file-like object — **not** `True`.
+
+## 4. Rollback
+
+```bash
+uv remove pywhispercpp     # drops the dependency + source entry, then re-locks
+uv sync
+```
+
+## Gotchas
+
+- **The wheel is `cp313`-only.** Any interpreter switch (or a `requires-python`
+  change) breaks resolution; the dependency marker and the wheel tag must stay in
+  sync.
+- **The wheel is local-only** (`vendor/.gitignore` = `*`). A fresh clone has no
+  wheel and `uv sync` fails until step 2 is re-run. The clone itself is *not*
+  needed at runtime once the wheel is built (RUNPATH is `$ORIGIN`).
+- **No `LD_LIBRARY_PATH` needed.** The CUDA libs (`libcudart.so.13`,
+  `libcublas.so.13`, `libcublasLt.so.13`) resolve via `libggml-cuda`'s own
+  link-path rpath, so a plain `import` works.
+- **Vulkan device selection (if you switch to a Vulkan build):** ggml enumerates
+  the NVIDIA GPU, the AMD 610M iGPU, and llvmpipe. It may pick the iGPU first;
+  set `GGML_VULKAN_DEVICE=<index>` and confirm in the init log.
+- **Build time:** several minutes.
+
+## Integration
+
+Set `[whisper] backend = "pywhispercpp"` in `config.toml` to use it. The default is
+`faster-whisper`, so nothing changes unless you opt in.
+
+The backend lives behind `build_transcriber()`, which returns either the
+`faster-whisper` or the `pywhispercpp` implementation from the same config.
+`device` and `compute_type` are ignored: the GPU support comes from the built
+wheel and quantization is a property of the ggml model file, not a config key.
+`vad_filter` is also ignored, with a warning (we already run webrtcvad upstream).
+
+Everything else maps to the whisper.cpp equivalent: language auto-detect and
+sticky pinning, `no_speech_prob_max` → `no_speech_thold`, `avg_logprob_min` →
+segment probability gate, `beam_size` → beam search, and `cpu_threads`.
+
+## When this is worth doing
+
+The GPU build is verified ≈13× faster than the CPU wheel for the same `small`
+model (RTF 0.01 vs 0.18). The remaining question is whether whisper.cpp's
+transcript quality and latency beat the current `faster-whisper` CPU backend on
+real utterances — compare the two on the same clip before wiring anything in.

+ 5 - 0
pyproject.toml

@@ -17,6 +17,8 @@ dependencies = [
     "webrtcvad-wheels>=2.0.14",
     "questionary>=2.0",
     "textual>=0.80",
+    # Vendored local CUDA build (cp313-only); see docs/PYWHISPERCPP.md.
+    "pywhispercpp>=1.5.1 ; python_full_version == '3.13.*'",
 ]
 
 [project.optional-dependencies]
@@ -25,6 +27,9 @@ portaudio = ["sounddevice>=0.4.6"]
 [dependency-groups]
 dev = ["pytest>=8", "ruff"]
 
+[tool.uv.sources]
+pywhispercpp = { path = "vendor/pywhispercpp-1.5.1-cp313-cp313-linux_x86_64.whl" }
+
 [project.scripts]
 stt-daemon = "stt_daemon.cli:main"
 stt-assistant = "stt_daemon.assistant.runner:main"

+ 2 - 14
src/stt_daemon/cli.py

@@ -14,7 +14,7 @@ from .picker import select_sources
 from .provisioner import Provisioner
 from .pulse import PulseAudio
 from .state import StateStore
-from .transcriber import Transcriber
+from .transcriber import build_transcriber
 from .supervisor import Supervisor
 
 
@@ -469,19 +469,7 @@ def _check(args) -> int:
     print("Loading Whisper model...", file=sys.stderr, flush=True)
 
     try:
-        transcriber = Transcriber(
-            model=cfg.whisper.model,
-            device=cfg.whisper.device,
-            compute_type=cfg.whisper.compute_type,
-            language=cfg.whisper.language,
-            beam_size=cfg.whisper.beam_size,
-            vad_filter=cfg.whisper.vad_filter,
-            cpu_threads=cfg.whisper.cpu_threads,
-            no_speech_prob_max=cfg.whisper.no_speech_prob_max,
-            avg_logprob_min=cfg.whisper.avg_logprob_min,
-            language_strategy=cfg.whisper.language_strategy,
-            sticky_redetect_logprob=cfg.whisper.sticky_redetect_logprob,
-        )
+        transcriber = build_transcriber(cfg.whisper)
         transcriber.close()
         print("✓ Model ready", flush=True)
     except Exception as e:

+ 9 - 0
src/stt_daemon/config.py

@@ -50,6 +50,7 @@ class VadConfig:
 @dataclass
 class WhisperConfig:
     """Whisper transcription configuration."""
+    backend: str = "faster-whisper"
     model: str = "small"
     device: str = "cpu"
     compute_type: str = "int8"
@@ -233,6 +234,13 @@ def load_config(path: str | None = None) -> Config:
         # Whisper section
         if "whisper" in data:
             whisper_data = data["whisper"]
+            backend = whisper_data.get("backend", cfg.whisper.backend)
+            backend = backend.strip().lower().replace("_", "-")
+            if backend not in ("faster-whisper", "pywhispercpp"):
+                logger.warning(
+                    "Unknown whisper backend %r; falling back to 'faster-whisper'", backend
+                )
+                backend = "faster-whisper"
             language = whisper_data.get("language", cfg.whisper.language)
             # Normalize language: "", "auto", "none" -> None
             if language in ("", "auto", "none"):
@@ -244,6 +252,7 @@ def load_config(path: str | None = None) -> Config:
                 )
                 strategy = "static"
             cfg.whisper = WhisperConfig(
+                backend=backend,
                 model=whisper_data.get("model", cfg.whisper.model),
                 device=whisper_data.get("device", cfg.whisper.device),
                 compute_type=whisper_data.get("compute_type", cfg.whisper.compute_type),

+ 17 - 24
src/stt_daemon/daemon.py

@@ -17,7 +17,7 @@ from .bus import EventPublisher
 from .config import Config
 from .output import TranscriptSink
 from .segmenter import FrameSegmenter, Utterance, make_vad
-from .transcriber import Transcriber
+from .transcriber import TranscriberLike, build_transcriber
 
 
 class Daemon:
@@ -60,7 +60,7 @@ class Daemon:
         # Initialize components
         self.capture: Optional[AudioCapture] = None
         self.segmenter: Optional[FrameSegmenter] = None
-        self.transcriber: Optional[Transcriber] = None
+        self.transcriber: Optional[TranscriberLike] = None
         self.sink: Optional[TranscriptSink] = None
         self.publisher: Optional[EventPublisher] = None
 
@@ -97,20 +97,8 @@ class Daemon:
         """Ask the run loop to stop. Signal-safe: cleanup happens in stop()."""
         self._stop_event.set()
 
-    def _build_transcriber(self) -> Transcriber:
-        return Transcriber(
-            model=self.cfg.whisper.model,
-            device=self.cfg.whisper.device,
-            compute_type=self.cfg.whisper.compute_type,
-            language=self.cfg.whisper.language,
-            beam_size=self.cfg.whisper.beam_size,
-            vad_filter=self.cfg.whisper.vad_filter,
-            cpu_threads=self.cfg.whisper.cpu_threads,
-            no_speech_prob_max=self.cfg.whisper.no_speech_prob_max,
-            avg_logprob_min=self.cfg.whisper.avg_logprob_min,
-            language_strategy=self.cfg.whisper.language_strategy,
-            sticky_redetect_logprob=self.cfg.whisper.sticky_redetect_logprob,
-        )
+    def _build_transcriber(self) -> TranscriberLike:
+        return build_transcriber(self.cfg.whisper)
 
     def _build_sink(self) -> TranscriptSink:
         return TranscriptSink(
@@ -355,14 +343,19 @@ class Daemon:
                 started = time.perf_counter()
                 result = self.transcriber.transcribe(utt.audio)
                 transcribe_s = time.perf_counter() - started
-                audio_path = self.sink.emit(
-                    result,
-                    utt.duration_s,
-                    utt.rms,
-                    utt.reason,
-                    utt.audio,
-                    transcribe_s=transcribe_s,
-                )
+                try:
+                    audio_path = self.sink.emit(
+                        result,
+                        utt.duration_s,
+                        utt.rms,
+                        utt.reason,
+                        utt.audio,
+                        transcribe_s=transcribe_s,
+                    )
+                except Exception as e:
+                    # A sink failure must not drop the transcript from the bus.
+                    self.logger.error("Output error: %s", e)
+                    audio_path = None
                 if result.text:
                     emitted = True
                     self._last_transcript_ts = time.time()

+ 4 - 2
src/stt_daemon/output.py

@@ -118,9 +118,11 @@ class TranscriptSink:
             if transcribe_s is not None:
                 record["transcribe_s"] = round(transcribe_s, 3)
 
+            # Serialize before opening: a failure here must not leave a partial
+            # line in the JSONL file for the next record to append onto.
+            line = json.dumps(record, ensure_ascii=False)
             with open(jsonl_path, "a", encoding="utf-8") as f:
-                json.dump(record, f, ensure_ascii=False)
-                f.write("\n")
+                f.write(line + "\n")
 
         return str(audio_path) if audio_path is not None else None
 

+ 200 - 0
src/stt_daemon/pywhispercpp_transcriber.py

@@ -0,0 +1,200 @@
+"""Transcription using pywhispercpp (whisper.cpp bindings).
+
+Behavioral parity with :class:`stt_daemon.transcriber.Transcriber`, but the
+pywhispercpp API differs: language detection is an explicit call and each
+segment exposes a single geometric-mean ``probability`` instead of
+``avg_logprob``. pywhispercpp is only installed on Python 3.13, so the import
+stays lazy inside the constructor.
+"""
+
+import logging
+import math
+
+import numpy as np
+
+from .transcriber import TranscriptResult
+
+logger = logging.getLogger(__name__)
+
+
+def accept_segment(segment, avg_logprob_min: float) -> bool:
+    """Keep a pywhispercpp segment only when it clears the confidence floor.
+
+    pywhispercpp reports ``probability`` (geometric mean of token probabilities),
+    the probability-space equivalent of faster-whisper's ``avg_logprob``. A
+    missing or NaN probability is accepted, matching the faster-whisper gate's
+    "missing attributes default to accept" behavior.
+    """
+    probability = getattr(segment, "probability", math.nan)
+    if math.isnan(probability):
+        return True
+    return probability >= math.exp(avg_logprob_min)
+
+
+class PyWhisperCppTranscriber:
+    """Wrapper around pywhispercpp for transcription."""
+
+    def __init__(
+        self,
+        model: str,
+        language: str | None,
+        beam_size: int = 1,
+        cpu_threads: int = 0,
+        no_speech_prob_max: float = 0.6,
+        avg_logprob_min: float = -1.0,
+        language_strategy: str = "static",
+        sticky_redetect_logprob: float = -0.8,
+        vad_filter: bool = False,
+        model_class=None,
+    ):
+        """Initialize the transcriber.
+
+        Args:
+            model: Model name (e.g., "small", "base", "medium").
+            language: Language code (e.g., "en", "es") or None for auto-detect.
+            beam_size: Beam search width (1 = greedy, fastest).
+            cpu_threads: CPU threads for inference (0 = pywhispercpp's automatic choice).
+            no_speech_prob_max: Drop segments whisper.cpp scores as non-speech above this.
+            avg_logprob_min: Drop segments with confidence below this (log-probability space).
+            language_strategy: "static" (always auto-detect) or "sticky" (detect once, pin).
+            sticky_redetect_logprob: Threshold below which to clear the pin (re-detect).
+            vad_filter: Ignored; whisper.cpp has no equivalent built-in VAD filter.
+            model_class: Test seam; defaults to ``pywhispercpp.model.Model`` (lazy import).
+        """
+        self.language = language
+        self.beam_size = beam_size
+        self.cpu_threads = cpu_threads
+        self.no_speech_prob_max = no_speech_prob_max
+        self.avg_logprob_min = avg_logprob_min
+        self.language_strategy = language_strategy
+        self.sticky_redetect_logprob = sticky_redetect_logprob
+        self.vad_filter = vad_filter
+        self._pinned_language: str | None = None
+        self._pinned_language_probability: float = 1.0
+
+        if model_class is None:
+            from pywhispercpp.model import Model
+
+            model_class = Model
+        self._model_class = model_class
+
+        if vad_filter:
+            logger.warning(
+                "vad_filter is not supported by the pywhispercpp backend and is ignored"
+            )
+
+        model_kwargs = {
+            "model": model,
+            "params_sampling_strategy": 1 if beam_size > 1 else 0,
+            "no_context": True,
+            "print_progress": False,
+            "print_realtime": False,
+            "print_timestamps": False,
+            "print_special": False,
+        }
+        # pywhispercpp's param setters reject None for n_threads and an empty
+        # beam_search dict, so only pass them when actually set.
+        if cpu_threads:
+            model_kwargs["n_threads"] = cpu_threads
+        if beam_size > 1:
+            model_kwargs["beam_search"] = {"beam_size": beam_size, "patience": -1.0}
+        self._model = model_class(**model_kwargs)
+
+    def transcribe(self, audio: np.ndarray) -> TranscriptResult:
+        """Transcribe audio to text.
+
+        With sticky language strategy, the language is detected once and then
+        pinned for subsequent utterances to save computation. The pin is cleared
+        if a result looks untrustworthy (low confidence or no accepted segments).
+
+        Args:
+            audio: Audio array (float32, mono, 16 kHz).
+
+        Returns:
+            TranscriptResult with language, probability, and text.
+        """
+        # Decide which language to request
+        pinned_before = self._pinned_language
+        if self.language is not None:
+            language = self.language
+            language_probability = 1.0
+        elif self.language_strategy == "sticky" and pinned_before is not None:
+            language = pinned_before
+            language_probability = self._pinned_language_probability
+        else:
+            (language, language_probability), _ = self._model.auto_detect_language(audio)
+
+        segments = self._model.transcribe(
+            audio,
+            language=language,
+            extract_probability=True,
+            no_speech_thold=self.no_speech_prob_max,
+        )
+
+        # Collect accepted segments and track their confidence
+        accepted_segments = [
+            seg for seg in segments if accept_segment(seg, self.avg_logprob_min)
+        ]
+        text = "".join(seg.text for seg in accepted_segments).strip()
+
+        # Sticky pinning logic (only when auto-detecting with sticky strategy)
+        # pywhispercpp returns numpy scalars; coerce so no np.float32 leaks into
+        # the JSON-encoded event bus or the JSONL sink.
+        probability_to_report = float(language_probability)
+        if self.language is None and self.language_strategy == "sticky":
+            if pinned_before is None:
+                # This is the first detection (no pin yet)
+                self._pinned_language = language
+                self._pinned_language_probability = float(language_probability)
+                logger.info(
+                    "Pinned language to %r (probability %.3f)",
+                    self._pinned_language,
+                    self._pinned_language_probability,
+                )
+            else:
+                # We already had a pin; check if we should clear it
+                should_clear = False
+                reason = ""
+                if not accepted_segments:
+                    should_clear = True
+                    reason = "no accepted segments"
+                else:
+                    max_probability = max(
+                        (
+                            p
+                            for p in (
+                                getattr(seg, "probability", math.nan)
+                                for seg in accepted_segments
+                            )
+                            if not math.isnan(p)
+                        ),
+                        default=math.nan,
+                    )
+                    if (
+                        not math.isnan(max_probability)
+                        and max_probability < math.exp(self.sticky_redetect_logprob)
+                    ):
+                        should_clear = True
+                        reason = (
+                            f"best segment probability {max_probability:.3f} < "
+                            f"exp(sticky_redetect_logprob) "
+                            f"{math.exp(self.sticky_redetect_logprob):.3f}"
+                        )
+                if should_clear:
+                    logger.info("Clearing language pin: %s", reason)
+                    self._pinned_language = None
+                    self._pinned_language_probability = 1.0
+            # Report the stored pin probability when pinned
+            if self._pinned_language is not None:
+                probability_to_report = float(self._pinned_language_probability)
+
+        return TranscriptResult(
+            language=str(language),
+            language_probability=float(probability_to_report),
+            text=text,
+        )
+
+    def close(self) -> None:
+        """Release the model."""
+        # pywhispercpp doesn't require explicit cleanup
+        pass

+ 55 - 0
src/stt_daemon/transcriber.py

@@ -2,12 +2,16 @@
 
 import logging
 from dataclasses import dataclass
+from typing import Protocol
 
 import numpy as np
 from faster_whisper import WhisperModel
 
 logger = logging.getLogger(__name__)
 
+BACKEND_FASTER_WHISPER = "faster-whisper"
+BACKEND_PYWHISPERCPP = "pywhispercpp"
+
 
 @dataclass
 class TranscriptResult:
@@ -182,3 +186,54 @@ class Transcriber:
         """Release the model."""
         # Faster-whisper doesn't require explicit cleanup
         pass
+
+
+class TranscriberLike(Protocol):
+    """Structural type shared by the transcription backends."""
+
+    def transcribe(self, audio: np.ndarray) -> TranscriptResult: ...
+
+    def close(self) -> None: ...
+
+
+def build_transcriber(cfg) -> TranscriberLike:
+    """Build the transcriber selected by ``cfg.backend``.
+
+    ``cfg`` is duck-typed (a ``WhisperConfig``) so this module never imports the
+    config layer. The pywhispercpp backend is imported lazily: it is only
+    installed on Python 3.13.
+
+    Args:
+        cfg: Whisper configuration with the model/decoding fields.
+
+    Returns:
+        A transcriber for the configured backend.
+    """
+    if cfg.backend == BACKEND_PYWHISPERCPP:
+        from .pywhispercpp_transcriber import PyWhisperCppTranscriber
+
+        return PyWhisperCppTranscriber(
+            model=cfg.model,
+            language=cfg.language,
+            beam_size=cfg.beam_size,
+            cpu_threads=cfg.cpu_threads,
+            no_speech_prob_max=cfg.no_speech_prob_max,
+            avg_logprob_min=cfg.avg_logprob_min,
+            language_strategy=cfg.language_strategy,
+            sticky_redetect_logprob=cfg.sticky_redetect_logprob,
+            vad_filter=cfg.vad_filter,
+        )
+
+    return Transcriber(
+        model=cfg.model,
+        device=cfg.device,
+        compute_type=cfg.compute_type,
+        language=cfg.language,
+        beam_size=cfg.beam_size,
+        vad_filter=cfg.vad_filter,
+        cpu_threads=cfg.cpu_threads,
+        no_speech_prob_max=cfg.no_speech_prob_max,
+        avg_logprob_min=cfg.avg_logprob_min,
+        language_strategy=cfg.language_strategy,
+        sticky_redetect_logprob=cfg.sticky_redetect_logprob,
+    )

+ 62 - 0
tests/test_config.py

@@ -442,3 +442,65 @@ max_prompt_tokens = 50
     assert "max_prompt_tokens" in caplog.text
     assert "deprecated" in caplog.text
     assert cfg.llm.prompt_budget == 131072 - 1024 - 256
+
+
+def test_backend_defaults_to_faster_whisper():
+    """backend defaults to 'faster-whisper'."""
+    with tempfile.TemporaryDirectory() as tmpdir:
+        import os
+        old_cwd = os.getcwd()
+        try:
+            os.chdir(tmpdir)
+            cfg = load_config()
+        finally:
+            os.chdir(old_cwd)
+
+    assert cfg.whisper.backend == "faster-whisper"
+
+
+def test_backend_pywhispercpp_is_parsed():
+    """backend 'pywhispercpp' is parsed from TOML."""
+    config_toml = """
+[whisper]
+backend = "pywhispercpp"
+"""
+    with tempfile.TemporaryDirectory() as tmpdir:
+        config_path = Path(tmpdir) / "config.toml"
+        config_path.write_text(config_toml)
+
+        cfg = load_config(str(config_path))
+
+    assert cfg.whisper.backend == "pywhispercpp"
+
+
+def test_backend_is_normalized():
+    """Backend names are normalized: stripped, lowercased, underscores to dashes."""
+    config_toml = """
+[whisper]
+backend = "  Faster_Whisper  "
+"""
+    with tempfile.TemporaryDirectory() as tmpdir:
+        config_path = Path(tmpdir) / "config.toml"
+        config_path.write_text(config_toml)
+
+        cfg = load_config(str(config_path))
+
+    assert cfg.whisper.backend == "faster-whisper"
+
+
+def test_unknown_backend_falls_back_to_faster_whisper(caplog):
+    """An unknown backend value falls back to 'faster-whisper' with a warning."""
+    config_toml = """
+[whisper]
+backend = "bogus"
+"""
+    with tempfile.TemporaryDirectory() as tmpdir:
+        config_path = Path(tmpdir) / "config.toml"
+        config_path.write_text(config_toml)
+
+        with caplog.at_level("WARNING", logger="stt_daemon.config"):
+            cfg = load_config(str(config_path))
+
+    assert cfg.whisper.backend == "faster-whisper"
+    assert "Unknown whisper backend" in caplog.text
+    assert "bogus" in caplog.text

+ 111 - 1
tests/test_daemon.py

@@ -1,9 +1,16 @@
-"""Tests for the daemon's transcription backlog warning."""
+"""Tests for the daemon's worker processing and transcription backlog warning."""
 
 import logging
 
+import numpy as np
+import pytest
+
+from stt_daemon import events
+from stt_daemon import transcriber as transcriber_mod
 from stt_daemon.config import Config
 from stt_daemon.daemon import Daemon
+from stt_daemon.segmenter import Utterance
+from stt_daemon.transcriber import TranscriptResult
 
 
 def _daemon(threshold=2, queue_size=10):
@@ -42,3 +49,106 @@ def test_backlog_warning_is_edge_triggered(caplog):
         daemon.segment_queue.put("d")
         daemon._check_backlog()
         assert caplog.text.lower().count("backlog") == 2
+
+
+class _FakeWhisperModel:
+    """Constructor-only fake; keeps faster-whisper from loading a real model."""
+
+    def __init__(self, *args, **kwargs):
+        self.init_kwargs = kwargs
+
+
+class _FakeCppModel:
+    """Constructor-only fake; keeps pywhispercpp from loading a real model."""
+
+    def __init__(self, *args, **kwargs):
+        self.init_kwargs = kwargs
+
+
+def test_build_transcriber_defaults_to_faster_whisper(monkeypatch):
+    monkeypatch.setattr(transcriber_mod, "WhisperModel", _FakeWhisperModel)
+    assert isinstance(_daemon()._build_transcriber(), transcriber_mod.Transcriber)
+
+
+def test_build_transcriber_honors_pywhispercpp_backend(monkeypatch):
+    cpp_model = pytest.importorskip("pywhispercpp.model")
+    monkeypatch.setattr(cpp_model, "Model", _FakeCppModel)
+    from stt_daemon.pywhispercpp_transcriber import PyWhisperCppTranscriber
+
+    cfg = Config()
+    cfg.whisper.backend = "pywhispercpp"
+    assert isinstance(Daemon(cfg)._build_transcriber(), PyWhisperCppTranscriber)
+
+
+# --- worker processing ------------------------------------------------------
+
+
+class _FakeTranscriber:
+    """Returns a canned result without loading a real model."""
+
+    def __init__(self, result):
+        self.result = result
+
+    def transcribe(self, audio):
+        return self.result
+
+    def close(self):
+        pass
+
+
+class _RaisingSink:
+    """Sink that fails on emit, simulating a non-JSON-serializable record."""
+
+    def emit(self, *args, **kwargs):
+        raise TypeError("Object of type float32 is not JSON serializable")
+
+    def close(self):
+        pass
+
+
+def _utterance(seg_id=1):
+    return Utterance(
+        audio=np.zeros(1600, dtype=np.float32),
+        duration_s=1.0,
+        started_at=0.0,
+        rms=0.05,
+        reason="pause",
+        seg_id=seg_id,
+    )
+
+
+def _transcript_result():
+    return TranscriptResult(language="en", language_probability=0.9, text="hello world")
+
+
+def _daemon_with_raising_sink():
+    daemon = _daemon()
+    daemon.transcriber = _FakeTranscriber(_transcript_result())
+    daemon.sink = _RaisingSink()
+    published = []
+    daemon._publish = lambda type_, **fields: published.append((type_, fields))
+    return daemon, published
+
+
+def test_sink_failure_still_publishes_transcript(caplog):
+    daemon, published = _daemon_with_raising_sink()
+
+    with caplog.at_level(logging.ERROR, logger="stt_daemon"):
+        daemon._process(_utterance())
+
+    transcripts = [fields for type_, fields in published if type_ == events.TRANSCRIPT]
+    assert len(transcripts) == 1
+    assert transcripts[0]["text"] == "hello world"
+    assert transcripts[0]["lang_prob"] == 0.9
+    assert transcripts[0]["audio"] is None
+    assert "Output error" in caplog.text
+
+
+def test_sink_failure_reports_segment_done_as_emitted():
+    daemon, published = _daemon_with_raising_sink()
+
+    daemon._process(_utterance())
+
+    done = [fields for type_, fields in published if type_ == events.SEGMENT_DONE]
+    assert len(done) == 1
+    assert done[0]["emitted"] is True

+ 15 - 0
tests/test_output.py

@@ -4,6 +4,7 @@ import json
 import wave
 
 import numpy as np
+import pytest
 
 from stt_daemon.output import TranscriptSink
 from stt_daemon.transcriber import TranscriptResult
@@ -191,3 +192,17 @@ def test_failed_audio_save_is_non_fatal(tmp_path, monkeypatch, capsys, caplog):
     assert "Failed to save audio" in caplog.text
 
 
+def test_failed_serialization_leaves_jsonl_untouched(tmp_path):
+    """A record that cannot be serialized must not leave a partial line behind."""
+    sink = TranscriptSink(stdout=False, jsonl_dir=str(tmp_path), jsonl=True)
+    sink.emit(_result(), 2.5, rms=0.04, reason="pause")
+    path = next(tmp_path.glob("*.jsonl"))
+    before = path.read_text()
+
+    bad = TranscriptResult(language="en", language_probability=np.float32(0.98), text="boom")
+    with pytest.raises(TypeError):
+        sink.emit(bad, 2.5, rms=0.04, reason="pause")
+
+    assert path.read_text() == before
+
+

+ 309 - 0
tests/test_pywhispercpp_transcriber.py

@@ -0,0 +1,309 @@
+"""Tests for the pywhispercpp transcriber backend."""
+
+import json
+import logging
+import math
+
+import numpy as np
+import pytest
+
+from stt_daemon.pywhispercpp_transcriber import PyWhisperCppTranscriber, accept_segment
+
+
+class _Seg:
+    def __init__(self, text="hello", probability=0.9):
+        self.text = text
+        self.probability = probability
+
+
+class _FakeModel:
+    """Records constructor/transcribe kwargs without loading a real model."""
+
+    last = None
+
+    def __init__(self, model, **kwargs):
+        self.init_kwargs = {"model": model, **kwargs}
+        self.transcribe_calls = []
+        self.auto_detect_calls = 0
+        self.segments_to_return = []
+        self.detected_language = "en"
+        self.detected_language_probability: float | np.float32 = 1.0
+        _FakeModel.last = self
+
+    def auto_detect_language(self, audio):
+        self.auto_detect_calls += 1
+        return (self.detected_language, self.detected_language_probability), {}
+
+    def transcribe(self, audio, **kwargs):
+        self.transcribe_calls.append(kwargs)
+        return self.segments_to_return
+
+
+def _audio():
+    return np.zeros(16000, dtype=np.float32)
+
+
+def _transcriber(**kwargs):
+    kwargs.setdefault("model", "small")
+    kwargs.setdefault("language", None)
+    kwargs.setdefault("model_class", _FakeModel)
+    return PyWhisperCppTranscriber(**kwargs)
+
+
+# --- accept_segment gate ---------------------------------------------------
+
+
+def test_accepts_probability_above_floor():
+    assert accept_segment(_Seg(probability=0.9), -1.0)
+
+
+def test_rejects_probability_below_floor():
+    assert not accept_segment(_Seg(probability=math.exp(-1.0) - 0.01), -1.0)
+
+
+def test_probability_at_floor_boundary_is_accepted():
+    assert accept_segment(_Seg(probability=math.exp(-1.0)), -1.0)
+
+
+def test_nan_probability_is_accepted():
+    assert accept_segment(_Seg(probability=math.nan), -1.0)
+
+
+def test_missing_probability_defaults_to_accept():
+    class Bare:
+        text = "x"
+
+    assert accept_segment(Bare(), -1.0)
+
+
+# --- constructor wiring ----------------------------------------------------
+
+
+def test_cpu_threads_forwarded_as_n_threads():
+    _transcriber(cpu_threads=16)
+    assert _FakeModel.last.init_kwargs["n_threads"] == 16
+
+
+def test_cpu_threads_default_is_auto():
+    _transcriber()
+    # Omitted so pywhispercpp picks its own thread count (it rejects None).
+    assert "n_threads" not in _FakeModel.last.init_kwargs
+
+
+def test_language_omitted_from_model_constructor():
+    """pywhispercpp rejects 'auto' ("unknown language"); language is set per call."""
+    _transcriber()
+    assert "language" not in _FakeModel.last.init_kwargs
+
+
+def test_beam_size_five_enables_beam_search():
+    _transcriber(beam_size=5)
+    kwargs = _FakeModel.last.init_kwargs
+    assert kwargs["params_sampling_strategy"] == 1
+    assert kwargs["beam_search"] == {"beam_size": 5, "patience": -1.0}
+
+
+def test_beam_size_one_uses_greedy():
+    _transcriber(beam_size=1)
+    kwargs = _FakeModel.last.init_kwargs
+    assert kwargs["params_sampling_strategy"] == 0
+    # Omitted so pywhispercpp keeps its default (it rejects an empty dict).
+    assert "beam_search" not in kwargs
+
+
+def test_vad_filter_warns_and_is_ignored(caplog):
+    with caplog.at_level(logging.WARNING):
+        _transcriber(vad_filter=True)
+    assert "vad_filter" in caplog.text
+    assert _FakeModel.last.init_kwargs["model"] == "small"
+
+
+def test_close_is_noop():
+    assert _transcriber().close() is None
+
+
+# --- transcribe: language handling -----------------------------------------
+
+
+def test_forced_language_skips_detection():
+    t = _transcriber(language="es")
+    result = t.transcribe(_audio())
+    assert _FakeModel.last.auto_detect_calls == 0
+    assert _FakeModel.last.transcribe_calls[0]["language"] == "es"
+    assert result.language == "es"
+    assert result.language_probability == 1.0
+
+
+def test_auto_detect_propagates_language_and_probability():
+    t = _transcriber(language=None)
+    _FakeModel.last.detected_language = "es"
+    _FakeModel.last.detected_language_probability = 0.93
+
+    result = t.transcribe(_audio())
+
+    assert _FakeModel.last.auto_detect_calls == 1
+    assert _FakeModel.last.transcribe_calls[0]["language"] == "es"
+    assert result.language == "es"
+    assert result.language_probability == 0.93
+
+
+def test_auto_detect_probability_is_builtin_float():
+    """np.float32 from pywhispercpp is not JSON-serializable by the event bus."""
+    t = _transcriber(language=None)
+    _FakeModel.last.detected_language = "es"
+    _FakeModel.last.detected_language_probability = np.float32(0.93)
+
+    result = t.transcribe(_audio())
+
+    assert type(result.language) is str
+    assert type(result.language_probability) is float
+    json.dumps({"lang": result.language, "lang_prob": result.language_probability})
+
+
+def test_forced_language_probability_is_builtin_float():
+    result = _transcriber(language="es").transcribe(_audio())
+
+    assert type(result.language_probability) is float
+    json.dumps({"lang_prob": result.language_probability})
+
+
+def test_explicit_language_with_sticky_ignores_sticky():
+    t = _transcriber(language="es", language_strategy="sticky")
+    t.transcribe(_audio())
+    t.transcribe(_audio())
+    assert _FakeModel.last.auto_detect_calls == 0
+    assert t._pinned_language is None
+
+
+# --- transcribe: segment gate and text -------------------------------------
+
+
+def test_no_speech_thold_forwards_no_speech_prob_max():
+    t = _transcriber(no_speech_prob_max=0.75)
+    t.transcribe(_audio())
+    kwargs = _FakeModel.last.transcribe_calls[0]
+    assert kwargs["no_speech_thold"] == 0.75
+    assert kwargs["extract_probability"] is True
+
+
+def test_probability_gate_drops_low_and_keeps_high():
+    t = _transcriber(avg_logprob_min=-1.0)
+    _FakeModel.last.segments_to_return = [
+        _Seg(text="bad ", probability=math.exp(-1.0) - 0.05),
+        _Seg(text="good", probability=0.9),
+    ]
+    assert t.transcribe(_audio()).text == "good"
+
+
+def test_text_is_joined_and_stripped():
+    t = _transcriber()
+    _FakeModel.last.segments_to_return = [
+        _Seg(text=" hola ", probability=0.9),
+        _Seg(text="mundo ", probability=0.9),
+    ]
+    assert t.transcribe(_audio()).text == "hola mundo"
+
+
+# --- sticky strategy --------------------------------------------------------
+
+
+def test_sticky_pins_on_first_call_and_reuses_pin():
+    t = _transcriber(language=None, language_strategy="sticky")
+    _FakeModel.last.detected_language = "es"
+    _FakeModel.last.detected_language_probability = 0.95
+    _FakeModel.last.segments_to_return = [_Seg(probability=0.9)]
+
+    first = t.transcribe(_audio())
+    assert first.language_probability == 0.95
+    assert t._pinned_language == "es"
+    assert t._pinned_language_probability == 0.95
+
+    second = t.transcribe(_audio())
+    assert _FakeModel.last.auto_detect_calls == 1  # pin reused, no second detection
+    assert _FakeModel.last.transcribe_calls[1]["language"] == "es"
+    assert second.language == "es"
+    assert second.language_probability == 0.95
+
+
+def test_sticky_pinned_probability_is_builtin_float():
+    """The pin must store a builtin float, not the np.float32 detection result."""
+    t = _transcriber(language=None, language_strategy="sticky")
+    _FakeModel.last.detected_language = "es"
+    _FakeModel.last.detected_language_probability = np.float32(0.95)
+    _FakeModel.last.segments_to_return = [_Seg(probability=0.9)]
+
+    first = t.transcribe(_audio())
+    second = t.transcribe(_audio())
+
+    assert type(first.language_probability) is float
+    assert type(t._pinned_language_probability) is float
+    assert type(second.language_probability) is float
+    json.dumps({"lang_prob": second.language_probability})
+
+
+def test_sticky_clears_pin_on_low_probability():
+    t = _transcriber(
+        language=None,
+        language_strategy="sticky",
+        sticky_redetect_logprob=-0.8,
+        avg_logprob_min=-1.0,
+    )
+    _FakeModel.last.detected_language = "en"
+    _FakeModel.last.detected_language_probability = 0.98
+    _FakeModel.last.segments_to_return = [_Seg(probability=0.9)]
+
+    t.transcribe(_audio())
+    assert t._pinned_language == "en"
+
+    # Accepted (0.40 >= exp(-1.0)) but below exp(-0.8) -> pin cleared
+    _FakeModel.last.segments_to_return = [_Seg(probability=0.40)]
+    t.transcribe(_audio())
+    assert t._pinned_language is None
+
+
+def test_sticky_clears_pin_when_no_segments_accepted():
+    t = _transcriber(language=None, language_strategy="sticky")
+    _FakeModel.last.detected_language = "en"
+    _FakeModel.last.detected_language_probability = 0.99
+    _FakeModel.last.segments_to_return = [_Seg(probability=0.9)]
+
+    t.transcribe(_audio())
+    assert t._pinned_language == "en"
+
+    _FakeModel.last.segments_to_return = [_Seg(probability=0.1)]
+    t.transcribe(_audio())
+    assert t._pinned_language is None
+
+
+def test_sticky_retains_pin_when_probability_missing():
+    """A NaN probability is accepted and does not trigger re-detection."""
+    t = _transcriber(language=None, language_strategy="sticky")
+    _FakeModel.last.detected_language = "en"
+    _FakeModel.last.detected_language_probability = 0.99
+    _FakeModel.last.segments_to_return = [_Seg(probability=0.9)]
+
+    t.transcribe(_audio())
+    assert t._pinned_language == "en"
+
+    _FakeModel.last.segments_to_return = [_Seg(probability=math.nan)]
+    t.transcribe(_audio())
+    assert t._pinned_language == "en"
+
+
+def test_static_strategy_never_pins():
+    t = _transcriber(language=None, language_strategy="static")
+    _FakeModel.last.detected_language = "es"
+
+    t.transcribe(_audio())
+    t.transcribe(_audio())
+
+    assert _FakeModel.last.auto_detect_calls == 2
+    assert t._pinned_language is None
+
+
+@pytest.mark.parametrize("strategy", ["static", "sticky"])
+def test_transcribe_receives_detected_language(strategy):
+    t = _transcriber(language=None, language_strategy=strategy)
+    _FakeModel.last.detected_language = "en"
+    t.transcribe(_audio())
+    assert _FakeModel.last.transcribe_calls[0]["language"] == "en"

+ 43 - 0
tests/test_transcriber.py

@@ -3,7 +3,10 @@
 import numpy as np
 import pytest
 
+from stt_daemon import pywhispercpp_transcriber as pw_mod
 from stt_daemon import transcriber as transcriber_mod
+from stt_daemon.config import Config
+from stt_daemon.pywhispercpp_transcriber import PyWhisperCppTranscriber
 from stt_daemon.transcriber import Transcriber, accept_segment
 
 
@@ -286,3 +289,43 @@ def test_sticky_tolerates_missing_avg_logprob_attribute(fake_model):
     # Should not raise; defaulted 0.0 is above threshold -0.8, so pin is RETAINED
     t.transcribe(_audio())
     assert t._pinned_language == "en"  # Pin retained because 0.0 >= -0.8
+
+
+class _StubPyWhisperCppTranscriber(PyWhisperCppTranscriber):
+    """Replaces the pywhispercpp backend without touching the real package."""
+
+    init_kwargs: dict
+
+    def __init__(self, **kwargs):
+        self.init_kwargs = kwargs
+
+
+def test_build_transcriber_faster_whisper_backend(fake_model):
+    cfg = Config().whisper
+    cfg.backend = "faster-whisper"
+
+    t = transcriber_mod.build_transcriber(cfg)
+
+    assert isinstance(t, Transcriber)
+    assert fake_model.last.init_kwargs["model"] == cfg.model
+    assert fake_model.last.init_kwargs["device"] == cfg.device
+    assert fake_model.last.init_kwargs["compute_type"] == cfg.compute_type
+
+
+def test_build_transcriber_pywhispercpp_backend(monkeypatch):
+    monkeypatch.setattr(pw_mod, "PyWhisperCppTranscriber", _StubPyWhisperCppTranscriber)
+    cfg = Config().whisper
+    cfg.backend = "pywhispercpp"
+
+    t = transcriber_mod.build_transcriber(cfg)
+
+    assert isinstance(t, PyWhisperCppTranscriber)
+    assert t.init_kwargs["model"] == cfg.model
+    assert t.init_kwargs["language"] == cfg.language
+    assert t.init_kwargs["beam_size"] == cfg.beam_size
+    assert t.init_kwargs["cpu_threads"] == cfg.cpu_threads
+    assert t.init_kwargs["no_speech_prob_max"] == cfg.no_speech_prob_max
+    assert t.init_kwargs["avg_logprob_min"] == cfg.avg_logprob_min
+    assert t.init_kwargs["language_strategy"] == cfg.language_strategy
+    assert t.init_kwargs["sticky_redetect_logprob"] == cfg.sticky_redetect_logprob
+    assert t.init_kwargs["vad_filter"] == cfg.vad_filter

+ 203 - 2
uv.lock

@@ -2,7 +2,8 @@ version = 1
 revision = 3
 requires-python = ">=3.11"
 resolution-markers = [
-    "python_full_version >= '3.12'",
+    "python_full_version == '3.13.*'",
+    "python_full_version == '3.12.*' or python_full_version >= '3.14'",
     "python_full_version < '3.12'",
 ]
 
@@ -152,6 +153,153 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" },
 ]
 
+[[package]]
+name = "charset-normalizer"
+version = "3.5.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/6a/b6/034f6802e9c3f6418966cfabb7db8c9252cc2429c5098f41cc43af804149/charset_normalizer-3.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eda059b6bc8bc0812d626fd91a7ce01bf583df0a61296eff390fd94141a34e30", size = 363585, upload-time = "2026-08-15T08:16:46.646Z" },
+    { url = "https://files.pythonhosted.org/packages/d5/fa/6a7e2a7c4b5451912b8c417732df79574354443592a88d616de03da66ae5/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa2bb0b37202dca27175591f761108b5d34096ade1191ffe4808bdf6b1571488", size = 251189, upload-time = "2026-08-15T08:16:48.287Z" },
+    { url = "https://files.pythonhosted.org/packages/a4/c8/ab42b07cfd82e919f427fcfaa7c41abae8242833ad1aad66d42bae40b669/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b2b1b3fa5670c127b246df1d0c059defd41f689a868a3b9d79df9b1cac42d22", size = 239724, upload-time = "2026-08-15T08:16:49.67Z" },
+    { url = "https://files.pythonhosted.org/packages/e7/80/b9348b5d3041209f98b4cdad7655766369233f1d533f4f4f7558e9717bec/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e5e4d73d588ca5ed09df1b7dcd1b203d1df3c542e3f50d126c947d432b10731", size = 280078, upload-time = "2026-08-15T08:16:51.228Z" },
+    { url = "https://files.pythonhosted.org/packages/82/38/083a24028304bc85bb9e376fed801178423dcbb67495f73b6ea0624e1894/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b54e7e13267d49ffbfe68e25b3cbd774dab38fa37238f71265e91b36146eb21c", size = 276650, upload-time = "2026-08-15T08:16:52.625Z" },
+    { url = "https://files.pythonhosted.org/packages/0d/35/731ac04aa0a097fc1c97f0994c375bdb230c6c96619db794208fe664e9ce/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7b742bf31c88566b4bb6335a7f393bb322e580b6bb98df7bd0c25e6e3519ce8", size = 262325, upload-time = "2026-08-15T08:16:54.085Z" },
+    { url = "https://files.pythonhosted.org/packages/f5/28/c2028e7021fb89c6e56868ed0e387b8e9aa811abdd2ab3208d6578d2c930/charset_normalizer-3.5.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ba32c4d2abf1d2fe7cf27d280f4cca5664233b0f885549c7761719eb977f486", size = 261140, upload-time = "2026-08-15T08:16:55.604Z" },
+    { url = "https://files.pythonhosted.org/packages/28/f0/0c0ceec6d98b7daa62e361e418135d59685811d79ba11529aad5cdf15e84/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0722590aabf9dc6a6c0343d523c05458fa2b5047dbe6302fd526bb570600753f", size = 252791, upload-time = "2026-08-15T08:16:57.103Z" },
+    { url = "https://files.pythonhosted.org/packages/f0/3e/48f4cd187b1c33189d86039e9cbe4f92c05454175504b44ff81806d4d1bf/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:aa1099b956fb795e686d073568f6dc002a0bb89765ea6d5b055dd7d9bf1b116c", size = 240730, upload-time = "2026-08-15T08:16:58.418Z" },
+    { url = "https://files.pythonhosted.org/packages/42/85/f9e22af69af67c54cce42be9455d9c81294f918b4ccc454db01f66efcac2/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bd6c173f04743d483881bffa1478d5a4624475b8cd1d2194956a75548e191c18", size = 280791, upload-time = "2026-08-15T08:16:59.918Z" },
+    { url = "https://files.pythonhosted.org/packages/fd/4c/9044135f42127630b6fa742feb51256353f6ab87a78f2fdd1de3de955a7f/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f298e218441525d3794428b4c8b8fb8662c6d3ea79925d4807ee6b9a96a3bca5", size = 259598, upload-time = "2026-08-15T08:17:01.421Z" },
+    { url = "https://files.pythonhosted.org/packages/ba/ed/1dd7cfebb4e75812934c49ca3b79757d11948053f7937ab7070c151f3c55/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6e2912d4babbc65196ac13c2f53468dc57fb8b9c25ef913e8c59ddf7c6dc0e1b", size = 278217, upload-time = "2026-08-15T08:17:02.782Z" },
+    { url = "https://files.pythonhosted.org/packages/bf/eb/239c84503cc9e3ba6eb34686a24bc66e84f3924efdd7e38e751a19f6bc10/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3d27167433c0d5f18dc850f07d0b3816221984fecdc405d6c157a6f0b8f8e9e6", size = 263417, upload-time = "2026-08-15T08:17:04.216Z" },
+    { url = "https://files.pythonhosted.org/packages/37/ab/4e4510e1e288478e2c8333131d1c1382382ba8cd2165053c79e39d1da961/charset_normalizer-3.5.1-cp311-cp311-win32.whl", hash = "sha256:ac00177c4831ffa650f8609e4bdddd5fe09c03b1c0c47acece7e6ea20421598b", size = 181774, upload-time = "2026-08-15T08:17:05.58Z" },
+    { url = "https://files.pythonhosted.org/packages/e3/57/32f0ccea59e8612057c61d6fd22ef2cb63cca93c9fe594094919696ac170/charset_normalizer-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:f9b1e28d0e8dbfa858abdba91d6b547beaf2df1a59bec6da6faae7b96a4991a9", size = 206653, upload-time = "2026-08-15T08:17:07.075Z" },
+    { url = "https://files.pythonhosted.org/packages/17/d4/b65c433fc521e58b5f54293982a5e51c05cb5f2dd3f1c7a6acb65b75324e/charset_normalizer-3.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:ae31a1a1db2ee6cc2942fccaf695c934bc7f3db9f2133a3fef1f367cf1a4ab10", size = 185630, upload-time = "2026-08-15T08:17:08.502Z" },
+    { url = "https://files.pythonhosted.org/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f", size = 344456, upload-time = "2026-08-15T08:17:10.072Z" },
+    { url = "https://files.pythonhosted.org/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa", size = 238530, upload-time = "2026-08-15T08:17:11.563Z" },
+    { url = "https://files.pythonhosted.org/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369", size = 230200, upload-time = "2026-08-15T08:17:12.919Z" },
+    { url = "https://files.pythonhosted.org/packages/e7/a0/47b18adeed31c8f16ba9700f32c1b18594cfa09f47eb672a488c273c22bf/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893", size = 262222, upload-time = "2026-08-15T08:17:14.571Z" },
+    { url = "https://files.pythonhosted.org/packages/38/fe/341861ac118dae06f3ec0eb487488af52128f2ef2faf0b11003944d22259/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0", size = 258951, upload-time = "2026-08-15T08:17:16.158Z" },
+    { url = "https://files.pythonhosted.org/packages/6f/89/bb5108dc6c3651dca963f2b0a3ba19bbcb370c94e1b6d3e0e844a58e6dca/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08", size = 248801, upload-time = "2026-08-15T08:17:17.683Z" },
+    { url = "https://files.pythonhosted.org/packages/b1/ba/ef83ae3aca816393decfa3530976f38a79812d707b80b580ac33b83f9877/charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada", size = 244070, upload-time = "2026-08-15T08:17:19.191Z" },
+    { url = "https://files.pythonhosted.org/packages/f6/0b/c5292a2462d69b7378ea89793bbb5b2b6fcf6f7dd6d1667f9619094ad553/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9", size = 240110, upload-time = "2026-08-15T08:17:20.547Z" },
+    { url = "https://files.pythonhosted.org/packages/46/22/111e5be3b740d5c2a5bfcedb3d237b6591e5c2e82ae9d6ffcb121fe0909c/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e", size = 232836, upload-time = "2026-08-15T08:17:21.895Z" },
+    { url = "https://files.pythonhosted.org/packages/f9/d2/d2aad6fe0dbb44b194bf3becb60f5a0ac48446ade999a47fe7bb41eb09a7/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6", size = 262712, upload-time = "2026-08-15T08:17:23.727Z" },
+    { url = "https://files.pythonhosted.org/packages/35/5a/337e4663a5eae6de99db940ee8066d4145caafb61327db62deda15313cce/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf", size = 242977, upload-time = "2026-08-15T08:17:25.157Z" },
+    { url = "https://files.pythonhosted.org/packages/ca/85/f82f8a92e31c7519410e2e1afdc630f28ec47490ce2c09a11c1a43cbb459/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71", size = 260207, upload-time = "2026-08-15T08:17:26.602Z" },
+    { url = "https://files.pythonhosted.org/packages/b7/52/643d11ffd60e9ac2fd1fb87e167a19285b9eefeff4a40e63c87cbfbeab36/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573", size = 250562, upload-time = "2026-08-15T08:17:27.971Z" },
+    { url = "https://files.pythonhosted.org/packages/62/16/46556278c2168d12df9da7fede5dc6fc70e60301b26a82bbeec238c9cfe3/charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2", size = 178507, upload-time = "2026-08-15T08:17:29.277Z" },
+    { url = "https://files.pythonhosted.org/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2", size = 200551, upload-time = "2026-08-15T08:17:30.668Z" },
+    { url = "https://files.pythonhosted.org/packages/cd/d7/eb95a042f0dd22e304b0b6472b154f3546a1a039a9ee89ccb2a7f61591fc/charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a", size = 180700, upload-time = "2026-08-15T08:17:32.028Z" },
+    { url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" },
+    { url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" },
+    { url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" },
+    { url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" },
+    { url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" },
+    { url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" },
+    { url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" },
+    { url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" },
+    { url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" },
+    { url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" },
+    { url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" },
+    { url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" },
+    { url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" },
+    { url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" },
+    { url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" },
+    { url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" },
+    { url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" },
+    { url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" },
+    { url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" },
+    { url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" },
+    { url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" },
+    { url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" },
+    { url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" },
+    { url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" },
+    { url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" },
+    { url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" },
+    { url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" },
+    { url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" },
+    { url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" },
+    { url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" },
+    { url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" },
+    { url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" },
+    { url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" },
+    { url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" },
+    { url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" },
+    { url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" },
+    { url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" },
+    { url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" },
+    { url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" },
+    { url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" },
+    { url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" },
+    { url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" },
+    { url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" },
+    { url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" },
+    { url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" },
+    { url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" },
+    { url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" },
+    { url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" },
+    { url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" },
+    { url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" },
+    { url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" },
+    { url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" },
+    { url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" },
+    { url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" },
+    { url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" },
+    { url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" },
+    { url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" },
+    { url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" },
+    { url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" },
+    { url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" },
+    { url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" },
+    { url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" },
+    { url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" },
+    { url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" },
+    { url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" },
+    { url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" },
+    { url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" },
+    { url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" },
+    { url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" },
+    { url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" },
+    { url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" },
+    { url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" },
+    { url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" },
+    { url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" },
+    { url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" },
+    { url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" },
+    { url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" },
+    { url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" },
+    { url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" },
+    { url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" },
+    { url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" },
+    { url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" },
+    { url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" },
+    { url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" },
+    { url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" },
+    { url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" },
+    { url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" },
+    { url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" },
+    { url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" },
+    { url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" },
+    { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" },
+    { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" },
+    { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" },
+    { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" },
+    { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" },
+    { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" },
+    { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" },
+    { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" },
+    { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" },
+    { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" },
+    { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" },
+    { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" },
+    { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" },
+    { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" },
+    { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" },
+    { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" },
+    { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" },
+]
+
 [[package]]
 name = "click"
 version = "8.5.0"
@@ -482,7 +630,8 @@ name = "numpy"
 version = "2.5.3"
 source = { registry = "https://pypi.org/simple" }
 resolution-markers = [
-    "python_full_version >= '3.12'",
+    "python_full_version == '3.13.*'",
+    "python_full_version == '3.12.*' or python_full_version >= '3.14'",
 ]
 sdist = { url = "https://files.pythonhosted.org/packages/13/01/11703282db468b85f6f7b8c7f22d058de5970d5c7e60a3a8aaa313c3de36/numpy-2.5.3.tar.gz", hash = "sha256:df2d5874ff183595a4ba404edd04f6bd9b5505c1d7708573f6a6c17489a67563", size = 20791231, upload-time = "2026-09-06T16:27:47.073Z" }
 wheels = [
@@ -679,6 +828,32 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
 ]
 
+[[package]]
+name = "pywhispercpp"
+version = "1.5.1"
+source = { path = "vendor/pywhispercpp-1.5.1-cp313-cp313-linux_x86_64.whl" }
+dependencies = [
+    { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" } },
+    { name = "platformdirs" },
+    { name = "requests" },
+    { name = "tqdm" },
+]
+wheels = [
+    { filename = "pywhispercpp-1.5.1-cp313-cp313-linux_x86_64.whl", hash = "sha256:be6d980ca114720c43276bad0633b972342d4801cbc2c80b8957c6bd5505fa20" },
+]
+
+[package.metadata]
+requires-dist = [
+    { name = "numpy" },
+    { name = "platformdirs" },
+    { name = "pyqt5", marker = "extra == 'gui'" },
+    { name = "requests" },
+    { name = "sounddevice", marker = "extra == 'examples'" },
+    { name = "tqdm" },
+    { name = "webrtcvad", marker = "extra == 'examples'" },
+]
+provides-extras = ["examples", "gui"]
+
 [[package]]
 name = "pyyaml"
 version = "6.0.3"
@@ -746,6 +921,21 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" },
 ]
 
+[[package]]
+name = "requests"
+version = "2.34.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "certifi" },
+    { name = "charset-normalizer" },
+    { name = "idna" },
+    { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
+]
+
 [[package]]
 name = "rich"
 version = "15.0.0"
@@ -808,6 +998,7 @@ dependencies = [
     { name = "faster-whisper" },
     { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
     { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
+    { name = "pywhispercpp", marker = "python_full_version == '3.13.*'" },
     { name = "questionary" },
     { name = "textual" },
     { name = "webrtcvad-wheels" },
@@ -828,6 +1019,7 @@ dev = [
 requires-dist = [
     { name = "faster-whisper", specifier = ">=1.1.0" },
     { name = "numpy", specifier = ">=1.26" },
+    { name = "pywhispercpp", marker = "python_full_version == '3.13.*'", path = "vendor/pywhispercpp-1.5.1-cp313-cp313-linux_x86_64.whl" },
     { name = "questionary", specifier = ">=2.0" },
     { name = "sounddevice", marker = "extra == 'portaudio'", specifier = ">=0.4.6" },
     { name = "textual", specifier = ">=0.80" },
@@ -906,6 +1098,15 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
 ]
 
+[[package]]
+name = "urllib3"
+version = "2.8.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
+]
+
 [[package]]
 name = "wcwidth"
 version = "0.8.4"