Efren Yevale Varela пре 1 месец
комит
a0adf9ae9d

+ 17 - 0
.gitignore

@@ -0,0 +1,17 @@
+.venv/
+venv/
+__pycache__/
+*.pyc
+transcripts/
+models/
+*.onnx
+*.wav
+.ruff_cache/
+.pytest_cache/
+config.toml
+*.egg-info/
+.eggs/
+dist/
+build/
+.omo/
+.codegraph

+ 1 - 0
.python-version

@@ -0,0 +1 @@
+3.13

+ 559 - 0
README.md

@@ -0,0 +1,559 @@
+# STT Daemon
+
+An always-on speech-to-text daemon that continuously listens to a PulseAudio/PipeWire audio source, detects voice activity with webrtcvad, and transcribes utterances to Spanish/English text using Faster-Whisper.
+
+## Overview
+
+This daemon is designed to:
+
+- **Capture audio continuously** from PulseAudio/PipeWire sources
+- **Preserve the start of speech** using a pre-roll (ring) buffer, so the first phoneme is never cut when voice detection fires
+- **Segment utterances** with configurable silence thresholds
+- **Transcribe** each utterance with Faster-Whisper (supports multilingual, Spanish, English, or auto-detect)
+- **Output to stdout** (human-readable) and/or JSONL files (for logging/downstream processing)
+- **Run non-blocking** with worker threads so audio keeps flowing during transcription
+
+### Architecture
+
+```
+Audio Source (PulseAudio)
+    ↓
+ParecCapture / SoundDeviceCapture
+    ↓
+Pre-roll Ring Buffer (VAD lookback)
+    ↓
+WebRTC-VAD (voice activity detection)
+    ↓
+FrameSegmenter (utterance boundaries)
+    ↓
+Queue (segment_queue)
+    ↓
+Faster-Whisper (transcription)
+    ↓
+TranscriptSink (stdout + JSONL)
+```
+
+## Prerequisites
+
+### System packages
+
+- **PulseAudio or PipeWire-Pulse**: `pactl`, `parec` command-line tools
+  - On Ubuntu/Debian: `apt-get install pulseaudio-utils` or `apt-get install pipewire pipewire-pulse`
+  - On Fedora: `dnf install pulseaudio-utils`
+  - On macOS: Install via Homebrew (though PulseAudio is Linux-first; `sounddevice` backend recommended on macOS)
+
+- **FFmpeg** (optional, used by Faster-Whisper for format conversion)
+  - On Ubuntu/Debian: `apt-get install ffmpeg`
+
+### Python
+
+- **Python 3.11+** (tested on 3.13.5)
+- **uv** package manager (https://docs.astral.sh/uv/)
+
+## Installation
+
+Clone the repo and install with `uv`:
+
+```bash
+cd /home/efren/Repositories/STT-TTS
+
+# Sync dependencies (installs into .venv)
+uv sync
+
+# (Optional) If you want to use the sounddevice backend (requires portaudio):
+uv sync --extra portaudio
+```
+
+## Configuration
+
+### Quick Start
+
+Copy the example config and edit as needed:
+
+```bash
+cp config.example.toml config.toml
+# Edit config.toml to adjust settings (see section below)
+```
+
+### Configuration Reference
+
+The config file is TOML. Here are all available options with defaults:
+
+```toml
+[audio]
+backend = "parec"                     # "parec" (PulseAudio by name) | "sounddevice"
+device  = "VirtualMicSink.monitor"    # PulseAudio source name (parec) or device index/name (sounddevice)
+sample_rate = 16000                   # Hz
+channels    = 1                       # Mono only
+frame_ms    = 30                      # webrtcvad requires 10, 20, or 30
+gain        = 1.0                     # Amplitude scaling (1.0 = no change, >1 = louder)
+
+[vad]
+aggressiveness   = 2                  # 0–3: higher = fewer false positives (speech misses more)
+pre_roll_ms      = 400                # Ring buffer duration: keep this many ms of pre-speech
+speech_start_ms  = 90                 # Consecutive speech frames to confirm speech start
+silence_ms       = 700                # Consecutive silence to end an utterance
+min_utterance_ms = 300                # Discard utterances shorter than this
+max_utterance_s  = 30.0               # Force-finalize if utterance exceeds this duration
+
+[whisper]
+model        = "small"                # "tiny", "base", "small", "medium", "large"
+device       = "cpu"                  # "cpu" or "cuda"
+compute_type = "int8"                 # "int8" (CPU) or "float16" (CUDA, slower but more accurate)
+language     = "auto"                 # "auto" (detect), "en", "es", or None/""/"none"
+beam_size    = 1                      # 1 = fast greedy, >1 = slower beam search
+vad_filter   = false                  # Use Whisper's built-in VAD (we already have webrtcvad)
+
+[sink]                                # Virtual audio sink configuration
+sink_name             = "VirtualMicSink"       # null-sink name
+remap_source_name     = "VirtualMic"           # Microphone source name
+loopback_latency_msec = 1                      # Module latency (ms)
+monitor_output        = false                  # Loop sink.monitor → default sink (OFF = no feedback risk)
+
+[output]
+stdout    = true                      # Print transcripts to stdout
+jsonl_dir = "transcripts"             # Write JSONL files (one per day) to this dir
+
+[sink]
+sink_name             = "VirtualMicSink"    # Name of the null sink to create
+remap_source_name     = "VirtualMic"        # Name of the exposed microphone
+loopback_latency_msec = 1                   # Loopback latency in ms (lower = faster, higher = smoother)
+monitor_output        = false               # Loop sink output back to default speaker (CAUTION: feedback risk!)
+```
+
+### Audio Source Setup
+
+By default, the daemon listens to `VirtualMicSink.monitor` (the monitor of the virtual sink). Apps route audio into `VirtualMicSink`, and the daemon captures the mix.
+
+#### Option 1: Use the CLI with interactive setup (recommended)
+
+```bash
+# Show devices and create a virtual sink (interactive picker)
+uv run stt-daemon setup
+
+# Check status
+uv run stt-daemon list
+
+# In another terminal, start the daemon
+uv run stt-daemon run
+
+# The daemon auto-uses the provisioned sink's monitor as the capture device
+# (no need to manually specify --device)
+
+# Route audio:
+#   - In pavucontrol (GUI), drag apps' "Recording" side to "VirtualMicSink"
+#   - Or use pactl: pactl move-sink-input <app-index> VirtualMicSink
+
+# Stop when done
+uv run stt-daemon clear --yes
+```
+
+#### Option 2: Use the provided shell wrapper
+
+For convenience, `scripts/audio-sink.sh` is a thin wrapper around the Python CLI:
+
+```bash
+scripts/audio-sink.sh start   # → uv run stt-daemon setup
+scripts/audio-sink.sh status  # → uv run stt-daemon list
+scripts/audio-sink.sh stop    # → uv run stt-daemon clear --yes
+```
+
+#### Option 3: Manual PulseAudio setup
+
+If you prefer manual control or want to understand what's happening:
+
+```bash
+# Get your default microphone source
+default_source=$(pactl get-default-source)
+echo $default_source  # e.g., alsa_input.usb-...
+
+# Create null sink
+pactl load-module module-null-sink sink_name=VirtualMicSink \
+    sink_properties=device.description=Virtual-Mic-Sink
+
+# Expose the sink's monitor as a microphone
+pactl load-module module-remap-source \
+    master=VirtualMicSink.monitor \
+    source_name=VirtualMic \
+    source_properties=device.description=Virtual-Microphone
+
+# Route your microphone into the sink
+pactl load-module module-loopback source="$default_source" sink=VirtualMicSink latency_msec=1
+
+# (Optional) Monitor the sink output on your speakers
+pactl load-module module-loopback source=VirtualMicSink.monitor latency_msec=1
+
+# Now tell the daemon to use it
+uv run stt-daemon --device VirtualMicSink.monitor
+
+# Clean up (unload modules and remove the file):
+pactl unload-module <sink_id>
+pactl unload-module <source_id>
+pactl unload-module <loopback_id_1>
+pactl unload-module <loopback_id_2>
+```
+
+#### Option 3: Use a physical microphone directly
+
+If you have a single physical mic and don't need the virtual routing:
+
+```bash
+# List available sources
+pactl list short sources
+
+# Start daemon with your source
+uv run stt-daemon --device alsa_input.usb-C-Media_Electronics_Inc._USB_Audio_Device-00.mono-fallback
+```
+
+#### Option 4: Use sounddevice backend
+
+If you prefer not to use PulseAudio (e.g., on macOS or Windows):
+
+```bash
+uv sync --extra portaudio
+uv run stt-daemon --backend sounddevice --device 0  # device 0 = default input
+```
+
+## Running the Daemon
+
+### Subcommands
+
+The `stt-daemon` CLI supports these subcommands:
+
+#### `setup` — Create virtual sink and loopbacks
+
+Interactive setup (shows device picker):
+
+```bash
+uv run stt-daemon setup
+```
+
+Non-interactive setup (specify sources directly):
+
+```bash
+uv run stt-daemon setup --sources "alsa_input.usb-Foo,alsa_output.pci-0000.monitor"
+```
+
+This creates:
+- A null sink (by default `VirtualMicSink`)
+- A remap-source to expose the sink's monitor as a microphone (by default `VirtualMic`)
+- Loopback modules routing each selected source into the sink
+- A state file tracking all module IDs for later cleanup
+
+#### `run` — Start the daemon (auto-provisions if needed)
+
+```bash
+uv run stt-daemon run
+```
+
+If no saved provisioning exists and the sink is missing, the daemon will:
+1. Show the interactive device picker
+2. Create the sink and loopbacks (if sources are selected)
+3. Start listening and transcribing
+
+You can also override settings:
+
+```bash
+uv run stt-daemon run --device "alsa_input.usb-..." --language es --model base
+uv run stt-daemon run -v  # Verbose logging
+uv run stt-daemon run --check  # Test audio capture + model, then exit
+```
+
+#### `list` — Show devices and provisioning status
+
+```bash
+uv run stt-daemon list
+```
+
+Output shows:
+- Input devices (real microphones)
+- Output devices (speakers/headphones; captured via their `.monitor` source)
+- Current provisioning state (if a sink has been created)
+
+#### `clear` — Remove virtual sink and loopbacks
+
+```bash
+uv run stt-daemon clear
+# or without confirmation prompt:
+uv run stt-daemon clear --yes
+```
+
+This unloads all tracked modules and removes the state file.
+
+#### (Legacy) `--list-sources` flag
+
+```bash
+uv run stt-daemon --list-sources
+# Equivalent to: uv run stt-daemon list
+```
+
+#### (Legacy) `--check` flag
+
+```bash
+uv run stt-daemon --check
+# Equivalent to: uv run stt-daemon run --check
+```
+
+### Interactive Device Picker
+
+When you run `setup` or `run` without `--sources`, the daemon shows an interactive picker:
+
+- **TTY mode** (terminal): Uses `questionary` to show a checkbox UI. Pre-checked items are from the previously saved selection.
+- **Non-TTY mode** (piped input): Shows a numbered menu; enter space/comma-separated indices, `all`, `none`, or blank (to keep previous selection).
+
+Device labels:
+- `[IN] <description> (<name>)` — Input device (real microphone)
+- `[OUT] <description> (<name>)` — Output device (monitor of a sink)
+
+### Running the daemon
+
+The simplest workflow:
+
+```bash
+# Create/setup the sink (interactive)
+uv run stt-daemon setup
+
+# Start the daemon in another terminal
+# (auto-uses the provisioned sink)
+uv run stt-daemon run
+
+# Stop and remove the sink when done
+uv run stt-daemon clear --yes
+```
+
+### Output examples
+
+**Stdout** (human-readable):
+```
+[14:23:45] (es) Hola, cómo estás?
+[14:23:52] (en) What time is it?
+[14:24:10] (es) Gracias por tu ayuda.
+```
+
+**JSONL** (one line per utterance):
+```json
+{"ts": "2025-09-20T14:23:45.123456", "lang": "es", "lang_prob": 0.95, "duration_s": 2.5, "text": "Hola, cómo estás?"}
+{"ts": "2025-09-20T14:23:52.654321", "lang": "en", "lang_prob": 0.98, "duration_s": 1.8, "text": "What time is it?"}
+```
+
+## Virtual Sink Provisioning: How It Works
+
+The daemon uses PulseAudio's module system to create a virtual microphone from multiple sources:
+
+### What Gets Created
+
+1. **Null Sink** (`module-null-sink`, e.g., `VirtualMicSink`)
+   - A sink that doesn't play anywhere; apps route audio into it instead.
+   
+2. **Remap Source** (`module-remap-source`, e.g., `VirtualMic`)
+   - Exposes the null-sink's monitor as a microphone so the daemon can capture from it.
+   
+3. **Loopback Modules** (`module-loopback`, one per selected source)
+   - Routes each selected input (microphone, speaker monitor, etc.) into the null-sink.
+
+### State Persistence
+
+When you run `uv run stt-daemon setup`, the daemon:
+- Creates all modules and records their numeric IDs
+- Saves the state to `~/.local/state/stt-daemon/audio.json` (or `$STT_DAEMON_STATE`)
+- Allows `run` to re-provision if the sink is missing (e.g., after reboot)
+- Allows `clear` to unload only the modules it created
+
+The state file is JSON and can be edited manually if needed, but the daemon handles it automatically.
+
+### Feedback Risk
+
+By default, `monitor_output = false`, so the sink's output is **not** looped back to the default speaker. If you enable it, speakers will play back everything captured in the sink—including your own voice—causing audio feedback. Keep it OFF unless you specifically want monitoring.
+
+## Pre-roll Buffer: Why It Matters
+
+The **pre-roll buffer** is a ring buffer that keeps the last N milliseconds (default 400ms) of audio BEFORE speech is detected. When the VAD confirms speech has started, those pre-roll frames are prepended to the utterance.
+
+**Without pre-roll:** The first phoneme of "hello" might be cut off—you'd get "llo" or "elo" because the VAD fires AFTER the first sound is already gone.
+
+**With pre-roll:** The entire "hello" is captured, including the initial "h" and "e", because the ring buffer preserved them.
+
+### How it works (under the hood)
+
+1. **Silence phase**: Frames are pushed into a ring buffer (maxlen = pre_roll_ms / frame_ms). Old frames fall off.
+2. **Speech detected**: VAD returns True for consecutive `speech_start_ms` worth of frames.
+3. **Speech confirmed**: `_begin()` is called. The entire ring buffer contents are copied into the segment, then regular frames append to it.
+4. **Silence finalized**: After `silence_ms` of silence, the segment is converted to an Utterance and emitted.
+
+## Troubleshooting
+
+### "Device not found"
+
+```
+Error: Device 'VirtualMicSink.monitor' not found. Available sources: [...]
+To create VirtualMicSink, run: scripts/audio-sink.sh start
+```
+
+**Fix:**
+```bash
+scripts/audio-sink.sh start
+```
+
+Then route audio into VirtualMicSink and try again.
+
+### High latency / slow transcription
+
+By default, the daemon uses `compute_type = "int8"` and `device = "cpu"`, which trades speed for accuracy. To speed up:
+
+1. **Reduce model size**: `--model base` or `--model tiny`
+2. **Enable GPU** (if you have CUDA): `--device cuda` with `compute_type float16`
+3. **Reduce beam_size**: Edit config to `beam_size = 1` (already default, very fast)
+
+Example:
+
+```bash
+uv run stt-daemon --model base --device cuda
+```
+
+### Spanish / English / Other languages
+
+The Faster-Whisper model is **multilingual by default**. It auto-detects language or can be forced:
+
+```bash
+uv run stt-daemon --language es   # Force Spanish
+uv run stt-daemon --language en   # Force English
+uv run stt-daemon --language auto # Auto-detect (default)
+```
+
+The output includes the detected language tag: `[14:23:45] (es) ...`.
+
+### Recognizing Spanish vs. English better
+
+- **Force the language**: Use `--language es` or `--language en`
+- **Use a larger model**: `--model medium` or `--model large` (slower, more accurate)
+- **Tune VAD aggressiveness**: Lower values (0–1) capture more, higher values (2–3) are stricter
+
+### Audio device not responding / parec hangs
+
+If `parec` seems stuck:
+
+1. Check if the source exists: `pactl list short sources`
+2. Kill the daemon (Ctrl+C)
+3. Verify PulseAudio is running: `systemctl status pulseaudio` or check journalctl
+4. Restart PulseAudio if needed: `systemctl restart pulseaudio`
+5. Re-run the daemon
+
+### Sounddevice backend issues
+
+If using `--backend sounddevice` and getting import errors:
+
+```bash
+uv sync --extra portaudio
+```
+
+Then verify available devices:
+
+```bash
+python -c "import sounddevice as sd; print(sd.query_devices())"
+```
+
+Use the device index or name as `--device 0` or `--device "Microphone (...)`.
+
+### No transcriptions appearing
+
+1. Check VAD is firing: `uv run stt-daemon -v` (verbose logging)
+2. Verify audio is loud enough: `uv run stt-daemon --check` (check RMS)
+3. Reduce `aggressiveness` (0–1 instead of 2–3) to detect quieter speech
+4. Check `min_utterance_ms` isn't too high (default 300ms should be fine)
+
+### Whisper model not loading
+
+```
+Error loading model: ...
+```
+
+The model is downloaded on first run (~1.5 GB for `small`). Check:
+
+1. Disk space: `df -h` (needs ~2 GB free)
+2. Internet: Model is downloaded from Hugging Face
+3. HF cache location: Models go to `~/.cache/huggingface/hub/` by default
+
+You can override the cache:
+
+```bash
+export HF_HOME=/path/to/cache
+uv run stt-daemon --model small
+```
+
+## Testing
+
+Run the test suite (no audio hardware required):
+
+```bash
+uv run pytest -v
+```
+
+Tests cover:
+
+- **Pre-roll buffer** preservation: confirm the first frames are not cut
+- **Silence finalization**: utterances end after N silence frames
+- **Min/max utterance duration**: blips are discarded, long utterances are finalized
+- **Config loading and language normalization**
+
+## Development
+
+### Code structure
+
+```
+src/stt_daemon/
+├── __init__.py          - Package metadata
+├── __main__.py          - Entry point for `python -m stt_daemon`
+├── cli.py               - Argument parsing and entry point
+├── config.py            - Config loading (TOML)
+├── audio.py             - Audio capture (parec, sounddevice)
+├── segmenter.py         - VAD + pre-roll buffer + utterance segmentation (core)
+├── transcriber.py       - Faster-Whisper wrapper
+├── output.py            - Sinks (stdout, JSONL)
+└── daemon.py            - Main orchestrator (threads, queues)
+
+tests/
+├── test_segmenter.py    - Pre-roll buffer, VAD, segmentation logic
+└── test_config.py       - Config loading
+
+scripts/
+└── audio-sink.sh        - PulseAudio virtual sink manager
+```
+
+### Running linters
+
+```bash
+uv run ruff check .
+```
+
+### Building/packaging
+
+```bash
+# Install hatchling if needed
+uv pip install hatchling
+
+# Build wheel
+uv run python -m build
+```
+
+## Limitations & Design Decisions
+
+1. **Mono only**: Stereo support can be added, but speech is typically mono in transcription pipelines
+2. **Single language per transcription**: Whisper detects one language per utterance; mixed-language speech is attributed to the dominant language
+3. **No LLM integration**: This is transcription-only; use the JSONL output to feed an LLM if needed
+4. **PulseAudio/PipeWire-Pulse**: The default `parec` backend assumes UNIX-like systems with PulseAudio. macOS/Windows should use `sounddevice`
+5. **Real-time**: Latency depends on frame duration (30ms), silence threshold (default 700ms), and model size. Expect ~1–3 second latency with `small` model on CPU
+6. **Thread-safe queues**: Reader thread (audio) is separate from worker thread (transcription) so audio never blocks waiting for transcription
+
+## References
+
+- **Faster-Whisper**: https://github.com/SYSTRAN/faster-whisper
+- **WebRTC VAD**: https://github.com/wiseman/py-webrtcvad
+- **PulseAudio**: https://www.freedesktop.org/wiki/Software/PulseAudio/
+
+## License
+
+This project is MIT-licensed. See LICENSE file (if included).
+
+---
+
+Made with ❤️ for Efren. Questions? Open an issue or reach out.

+ 33 - 0
config.example.toml

@@ -0,0 +1,33 @@
+[audio]
+backend = "parec"                     # "parec" (PulseAudio by name) | "sounddevice"
+device  = "VirtualMicSink.monitor"    # PulseAudio source name (parec) or device index/name (sounddevice)
+sample_rate = 16000
+channels    = 1
+frame_ms    = 30                      # webrtcvad requires 10/20/30 ms
+gain        = 1.0
+
+[vad]
+aggressiveness   = 2                  # webrtcvad 0-3
+pre_roll_ms      = 400                # ring buffer kept BEFORE speech is confirmed
+speech_start_ms  = 90                 # consecutive speech frames needed to confirm start
+silence_ms       = 700                # trailing silence that ends an utterance
+min_utterance_ms = 300                # discard utterances with less actual speech than this
+max_utterance_s  = 30.0               # force-finalize a very long utterance
+
+[whisper]
+model        = "small"                # multilingual (DO NOT use .en suffix)
+device       = "cpu"                  # "cpu" | "cuda"
+compute_type = "int8"                 # "int8" cpu, "float16" cuda
+language     = "auto"                 # "auto" | "en" | "es"
+beam_size    = 1
+vad_filter   = false
+
+[output]
+stdout    = true
+jsonl_dir = "transcripts"             # one JSONL file per day: YYYY-MM-DD.jsonl
+
+[sink]
+sink_name             = "VirtualMicSink"    # Name of the null sink to create
+remap_source_name     = "VirtualMic"        # Name of the exposed microphone
+loopback_latency_msec = 1                   # Loopback latency in ms
+monitor_output        = false               # Loop sink.monitor to default sink (AVOID: causes acoustic feedback)

+ 39 - 0
pyproject.toml

@@ -0,0 +1,39 @@
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[project]
+name = "stt-daemon"
+version = "0.1.0"
+description = "Always-on speech-to-text daemon with pre-roll buffer"
+requires-python = ">=3.11"
+authors = [{name = "Efren", email = "efren@example.com"}]
+dependencies = [
+    "numpy>=1.26",
+    "faster-whisper>=1.1.0",
+    "webrtcvad-wheels>=2.0.14",
+    "questionary>=2.0",
+]
+
+[project.optional-dependencies]
+portaudio = ["sounddevice>=0.4.6"]
+
+[dependency-groups]
+dev = ["pytest>=8", "ruff"]
+
+[project.scripts]
+stt-daemon = "stt_daemon.cli:main"
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/stt_daemon"]
+
+[tool.ruff]
+target-version = "py311"
+line-length = 100
+
+[tool.ruff.lint]
+select = ["E", "F", "W"]
+
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+python_files = ["test_*.py"]

+ 60 - 0
scripts/audio-sink.sh

@@ -0,0 +1,60 @@
+#!/usr/bin/env bash
+# Thin wrapper around the Python CLI for audio sink management.
+# Delegates to uv run stt-daemon.
+
+set -euo pipefail
+
+# Resolve the repo root from the script location
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(dirname "$SCRIPT_DIR")"
+
+help() {
+    cat << 'EOF'
+STT Daemon Audio Sink Manager (wrapper)
+
+Usage: scripts/audio-sink.sh [start|stop|status|help]
+
+Commands:
+  start   - Create virtual microphone (delegated to `uv run stt-daemon setup`)
+  stop    - Remove virtual microphone (delegated to `uv run stt-daemon clear --yes`)
+  status  - Show sink status (delegated to `uv run stt-daemon list`)
+  help    - Show this message
+
+Note: This is a thin wrapper. Use `uv run stt-daemon --help` for full CLI options.
+
+Example:
+  scripts/audio-sink.sh start
+  scripts/audio-sink.sh status
+  scripts/audio-sink.sh stop
+
+EOF
+}
+
+main() {
+    local cmd="${1:-help}"
+
+    case "$cmd" in
+        start)
+            cd "$REPO_ROOT"
+            uv run stt-daemon setup
+            ;;
+        stop)
+            cd "$REPO_ROOT"
+            uv run stt-daemon clear --yes
+            ;;
+        status)
+            cd "$REPO_ROOT"
+            uv run stt-daemon list
+            ;;
+        help|--help|-h)
+            help
+            ;;
+        *)
+            echo "Unknown command: $cmd" >&2
+            echo "Use 'scripts/audio-sink.sh help' for usage" >&2
+            return 1
+            ;;
+    esac
+}
+
+main "$@"

+ 5 - 0
src/stt_daemon/__init__.py

@@ -0,0 +1,5 @@
+"""
+STT Daemon: Always-on speech-to-text with pre-roll buffer.
+"""
+
+__version__ = "0.1.0"

+ 8 - 0
src/stt_daemon/__main__.py

@@ -0,0 +1,8 @@
+"""Entry point for python -m stt_daemon."""
+
+import sys
+
+from .cli import main
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 267 - 0
src/stt_daemon/audio.py

@@ -0,0 +1,267 @@
+"""Audio capture from PulseAudio or sounddevice backend."""
+
+import queue
+import subprocess
+from abc import ABC, abstractmethod
+from typing import Iterator
+
+import numpy as np
+
+from .config import AudioConfig
+
+
+def list_sources() -> list[str]:
+    """List available PulseAudio sources by name.
+
+    Returns:
+        List of source names (e.g., ["VirtualMicSink.monitor", "alsa_input.usb-..."]).
+    """
+    try:
+        result = subprocess.run(
+            ["pactl", "list", "short", "sources"],
+            capture_output=True,
+            text=True,
+            check=True,
+        )
+    except subprocess.CalledProcessError as e:
+        raise RuntimeError(
+            f"Failed to list PulseAudio sources: {e.stderr}"
+        ) from e
+    except FileNotFoundError as e:
+        raise RuntimeError(
+            "pactl not found. Install PulseAudio or PipeWire-Pulse."
+        ) from e
+
+    sources = []
+    for line in result.stdout.strip().split("\n"):
+        if not line:
+            continue
+        # Format: INDEX\tNAME\tMODULE\tARGS
+        parts = line.split("\t")
+        if len(parts) >= 2:
+            sources.append(parts[1])
+
+    return sources
+
+
+class AudioCapture(ABC):
+    """Abstract base for audio capture backends."""
+
+    @property
+    @abstractmethod
+    def frame_bytes(self) -> int:
+        """Number of bytes per frame."""
+        pass
+
+    @abstractmethod
+    def frames(self) -> Iterator[bytes]:
+        """Yield audio frames as raw bytes (s16le PCM)."""
+        pass
+
+    @abstractmethod
+    def close(self) -> None:
+        """Close and clean up."""
+        pass
+
+
+class ParecCapture(AudioCapture):
+    """Capture via parec (PulseAudio command-line tool)."""
+
+    def __init__(self, cfg: AudioConfig):
+        """Initialize parec capture.
+
+        Args:
+            cfg: AudioConfig with backend="parec", device, sample_rate, channels, frame_ms.
+
+        Raises:
+            RuntimeError: If the device is not found or parec fails.
+        """
+        self.cfg = cfg
+        self._proc = None
+
+        # Validate device exists
+        sources = list_sources()
+        if cfg.device not in sources:
+            raise RuntimeError(
+                f"Device '{cfg.device}' not found. Available sources: {sources}\n"
+                f"To create VirtualMicSink, run: scripts/audio-sink.sh start"
+            )
+
+        self._frame_bytes = round(cfg.sample_rate * cfg.frame_ms / 1000) * 2
+        self._open()
+
+    def _open(self) -> None:
+        """Start the parec process."""
+        cmd = [
+            "parec",
+            "--raw",
+            "--format=s16le",
+            f"--rate={self.cfg.sample_rate}",
+            f"--channels={self.cfg.channels}",
+            f"--device={self.cfg.device}",
+            "--latency-msec=20",
+        ]
+        self._proc = subprocess.Popen(
+            cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL
+        )
+
+    @property
+    def frame_bytes(self) -> int:
+        """Number of bytes per frame."""
+        return self._frame_bytes
+
+    def frames(self) -> Iterator[bytes]:
+        """Yield frames from parec stdout."""
+        if not self._proc or not self._proc.stdout:
+            raise RuntimeError("parec process not running")
+
+        try:
+            while True:
+                frame = self._proc.stdout.read(self._frame_bytes)
+                if not frame:
+                    raise RuntimeError("parec EOF (stream ended unexpectedly)")
+                if len(frame) < self._frame_bytes:
+                    raise RuntimeError(
+                        f"parec short read: {len(frame)} < {self._frame_bytes}"
+                    )
+
+                # Apply gain if needed
+                if self.cfg.gain != 1.0:
+                    frame = self._apply_gain(frame)
+
+                yield frame
+        except KeyboardInterrupt:
+            pass
+
+    def _apply_gain(self, frame: bytes) -> bytes:
+        """Apply gain to frame."""
+        audio = np.frombuffer(frame, dtype=np.int16).astype(np.float32)
+        audio *= self.cfg.gain
+        audio = np.clip(audio, -32768, 32767).astype(np.int16)
+        return audio.tobytes()
+
+    def close(self) -> None:
+        """Terminate parec process."""
+        if self._proc:
+            self._proc.terminate()
+            try:
+                self._proc.wait(timeout=2)
+            except subprocess.TimeoutExpired:
+                self._proc.kill()
+                self._proc.wait()
+
+
+class SoundDeviceCapture(AudioCapture):
+    """Capture via sounddevice library (requires optional portaudio extra)."""
+
+    def __init__(self, cfg: AudioConfig):
+        """Initialize sounddevice capture.
+
+        Args:
+            cfg: AudioConfig with backend="sounddevice", device, sample_rate, channels, frame_ms.
+
+        Raises:
+            ImportError: If sounddevice is not installed.
+        """
+        try:
+            import sounddevice as sd
+        except ImportError as e:
+            raise ImportError(
+                "sounddevice not installed. Install with: uv sync --extra portaudio"
+            ) from e
+
+        self.sd = sd
+        self.cfg = cfg
+        self._queue = None
+        self._stream = None
+        self._device = self._resolve_device(cfg.device)
+
+        self._frame_samples = round(cfg.sample_rate * cfg.frame_ms / 1000)
+        self._frame_bytes = self._frame_samples * cfg.channels * 2
+
+        self._open()
+
+    @staticmethod
+    def _resolve_device(device: str | int | None) -> int | str | None:
+        """Coerce a numeric string to int so PortAudio accepts it as an index."""
+        if isinstance(device, str) and device.strip().isdigit():
+            return int(device)
+        return device if device != "" else None
+
+    def _open(self) -> None:
+        """Start the audio stream."""
+        self._queue = queue.Queue(maxsize=100)
+
+        def callback(indata, frames, time_info, status):
+            if status:
+                print(f"Audio error: {status}", flush=True)
+            try:
+                assert self._queue is not None
+                self._queue.put(indata.tobytes(), block=False)
+            except Exception:
+                pass
+
+        self._stream = self.sd.InputStream(
+            samplerate=self.cfg.sample_rate,
+            channels=self.cfg.channels,
+            dtype="int16",
+            blocksize=self._frame_samples,
+            device=self._device,
+            callback=callback,
+        )
+        self._stream.start()
+
+    @property
+    def frame_bytes(self) -> int:
+        """Number of bytes per frame."""
+        return self._frame_bytes
+
+    def frames(self) -> Iterator[bytes]:
+        """Yield frames from queue."""
+        if not self._queue:
+            raise RuntimeError("sounddevice stream not initialized")
+
+        try:
+            while True:
+                frame = self._queue.get(timeout=5)
+
+                # Apply gain if needed
+                if self.cfg.gain != 1.0:
+                    frame = self._apply_gain(frame)
+
+                yield frame
+        except Exception:
+            pass
+
+    def _apply_gain(self, frame: bytes) -> bytes:
+        """Apply gain to frame."""
+        audio = np.frombuffer(frame, dtype=np.int16).astype(np.float32)
+        audio *= self.cfg.gain
+        audio = np.clip(audio, -32768, 32767).astype(np.int16)
+        return audio.tobytes()
+
+    def close(self) -> None:
+        """Close the stream."""
+        if self._stream:
+            self._stream.stop()
+            self._stream.close()
+
+
+def open_capture(cfg: AudioConfig) -> AudioCapture:
+    """Open an audio capture backend based on config.
+
+    Args:
+        cfg: AudioConfig with backend selection.
+
+    Returns:
+        AudioCapture implementation (ParecCapture or SoundDeviceCapture).
+
+    Raises:
+        ValueError: If backend is unknown.
+    """
+    if cfg.backend == "parec":
+        return ParecCapture(cfg)
+    elif cfg.backend == "sounddevice":
+        return SoundDeviceCapture(cfg)
+    else:
+        raise ValueError(f"Unknown backend: {cfg.backend}")

+ 441 - 0
src/stt_daemon/cli.py

@@ -0,0 +1,441 @@
+"""Command-line interface for STT daemon with PulseAudio sink management."""
+
+import argparse
+import logging
+import signal
+import sys
+
+import numpy as np
+
+from .audio import list_sources, open_capture
+from .config import load_config
+from .daemon import Daemon
+from .picker import select_sources
+from .provisioner import Provisioner
+from .pulse import PulseAudio
+from .state import StateStore
+from .transcriber import Transcriber
+
+
+def _own_device_names(cfg) -> set[str]:
+    """Devices created by this program; excluded so they can't loop into themselves."""
+    return {f"{cfg.sink.sink_name}.monitor", cfg.sink.remap_source_name}
+
+
+def main() -> int:
+    """Main entry point with subcommands."""
+    # Same flags as the top level, but SUPPRESS so a subcommand only overrides
+    # when the flag is actually given (allows `stt-daemon run --device ...`).
+    common = argparse.ArgumentParser(add_help=False)
+    common.add_argument("--config", type=str, default=argparse.SUPPRESS)
+    common.add_argument("--device", type=str, default=argparse.SUPPRESS)
+    common.add_argument("--backend", type=str, choices=["parec", "sounddevice"],
+                        default=argparse.SUPPRESS)
+    common.add_argument("--model", type=str, default=argparse.SUPPRESS)
+    common.add_argument("--language", type=str, choices=["auto", "en", "es"],
+                        default=argparse.SUPPRESS)
+    common.add_argument("-v", "--verbose", action="store_true",
+                        default=argparse.SUPPRESS)
+
+    parser = argparse.ArgumentParser(
+        description="Always-on STT daemon with pre-roll buffer and PulseAudio sink management"
+    )
+
+    # Top-level arguments (work with both subcommands and legacy behavior)
+    parser.add_argument(
+        "--config",
+        type=str,
+        help="Path to config.toml (default: config.toml in cwd)",
+    )
+    parser.add_argument(
+        "--device",
+        type=str,
+        help="Override audio device",
+    )
+    parser.add_argument(
+        "--backend",
+        type=str,
+        choices=["parec", "sounddevice"],
+        help="Override audio backend",
+    )
+    parser.add_argument(
+        "--model",
+        type=str,
+        help="Override Whisper model",
+    )
+    parser.add_argument(
+        "--language",
+        type=str,
+        choices=["auto", "en", "es"],
+        help="Override language (auto for auto-detect)",
+    )
+    parser.add_argument(
+        "-v", "--verbose",
+        action="store_true",
+        help="Enable debug logging",
+    )
+
+    # Legacy compatibility flags (behave like subcommands if used)
+    parser.add_argument(
+        "--list-sources",
+        action="store_true",
+        help="(Legacy) List available PulseAudio sources and exit",
+    )
+    parser.add_argument(
+        "--check",
+        action="store_true",
+        help="(Legacy) Test audio capture and model, then exit",
+    )
+
+    # Subcommands
+    subparsers = parser.add_subparsers(dest="command", help="Subcommand to run")
+
+    # setup subcommand
+    setup_parser = subparsers.add_parser(
+        "setup",
+        parents=[common],
+        help="Create virtual audio sink and loopbacks",
+    )
+    setup_parser.add_argument(
+        "--sources",
+        type=str,
+        help="Comma-separated source names (non-interactive). If omitted, show interactive picker.",
+    )
+    setup_parser.add_argument(
+        "--yes",
+        action="store_true",
+        help="Skip confirmation",
+    )
+
+    # run subcommand
+    run_parser = subparsers.add_parser(
+        "run",
+        parents=[common],
+        help="Run the daemon (auto-provision if needed)",
+    )
+    run_parser.add_argument(
+        "--check",
+        action="store_true",
+        default=argparse.SUPPRESS,
+        help="Test audio capture and model, then exit",
+    )
+
+    # list subcommand
+    subparsers.add_parser(
+        "list",
+        parents=[common],
+        help="List devices and current sink status",
+    )
+
+    # clear subcommand
+    clear_parser = subparsers.add_parser(
+        "clear",
+        parents=[common],
+        help="Remove virtual sink and loopbacks",
+    )
+    clear_parser.add_argument(
+        "--yes",
+        action="store_true",
+        help="Skip confirmation",
+    )
+
+    args = parser.parse_args()
+
+    # Handle legacy compatibility: --list-sources at top level
+    if args.list_sources:
+        return _handle_list_sources()
+
+    # Handle legacy compatibility: --check at top level
+    if args.check and not args.command:
+        return _check(args)
+
+    # Route to subcommand or default (run daemon)
+    if args.command == "setup":
+        return _handle_setup(args)
+    elif args.command == "run":
+        return _handle_run(args)
+    elif args.command == "list":
+        return _handle_list(args)
+    elif args.command == "clear":
+        return _handle_clear(args)
+    else:
+        # No subcommand given: run daemon with optional check
+        if hasattr(args, "check") and args.check:
+            return _check(args)
+        return _handle_run(args)
+
+
+def _handle_list_sources() -> int:
+    """Legacy: list raw PulseAudio source names."""
+    try:
+        sources = list_sources()
+        for src in sources:
+            print(src)
+        return 0
+    except Exception as e:
+        print(f"Error: {e}", file=sys.stderr)
+        return 1
+
+
+def _handle_setup(args) -> int:
+    """Setup: create virtual sink and loopbacks."""
+    try:
+        cfg = load_config(args.config)
+
+        pa = PulseAudio()
+        store = StateStore()
+        provisioner = Provisioner(pa, cfg.sink, store)
+
+        # Determine sources to use
+        existing = store.load()
+        preselected = existing.selected_sources if existing else []
+
+        if args.sources:
+            sources = [s.strip() for s in args.sources.split(",") if s.strip()]
+        else:
+            devices = pa.list_devices(exclude=_own_device_names(cfg))
+            sources = select_sources(devices, preselected=preselected)
+
+        if not sources:
+            print("No sources selected.", file=sys.stderr)
+            return 1
+
+        print(f"Creating virtual sink '{cfg.sink.sink_name}'...")
+        state = provisioner.setup(sources)
+
+        print(f"✓ Virtual sink created with {len(state.loopbacks)} loopback(s)")
+        print(f"  Sink: {state.sink_name}")
+        print(f"  Microphone: {state.remap_name}")
+        print(f"  Modules: {state.null_sink_module} (null-sink), {state.remap_module} (remap)")
+        for i, lb in enumerate(state.loopbacks, 1):
+            print(f"           {lb.module_id} (loopback {i}: {lb.source})")
+
+        return 0
+
+    except Exception as e:
+        print(f"Setup error: {e}", file=sys.stderr)
+        return 1
+
+
+def _handle_run(args) -> int:
+    """Run: start the daemon (provision if needed)."""
+    try:
+        # Handle per-subcommand --check
+        if hasattr(args, "check") and args.check:
+            return _check(args)
+
+        cfg = load_config(args.config)
+
+        # Override with CLI args
+        if args.device:
+            cfg.audio.device = args.device
+        if args.backend:
+            cfg.audio.backend = args.backend
+        if args.model:
+            cfg.whisper.model = args.model
+        if args.language:
+            cfg.whisper.language = None if args.language == "auto" else args.language
+
+        # Auto-provision if needed
+        pa = PulseAudio()
+        store = StateStore()
+        provisioner = Provisioner(pa, cfg.sink, store)
+
+        state = provisioner.ensure()
+        if state is None and not pa.sink_exists(cfg.sink.sink_name):
+            # No saved state and sink doesn't exist: provision interactively
+            devices = pa.list_devices(exclude=_own_device_names(cfg))
+            sources = select_sources(devices, preselected=[])
+            if sources:
+                state = provisioner.setup(sources)
+            else:
+                print("No sources selected. Cannot provision.", file=sys.stderr)
+                return 1
+
+        # Capture from the null-sink monitor (the mix of every selected source).
+        if state:
+            cfg.audio.device = f"{state.sink_name}.monitor"
+
+        # Run daemon
+        log_level = logging.DEBUG if args.verbose else logging.INFO
+        daemon = Daemon(cfg, log_level=log_level)
+
+        def handle_signal(signum, frame):
+            daemon.stop()
+            sys.exit(0)
+
+        signal.signal(signal.SIGINT, handle_signal)
+        signal.signal(signal.SIGTERM, handle_signal)
+
+        try:
+            daemon.run()
+        except Exception as e:
+            print(f"Fatal error: {e}", file=sys.stderr)
+            return 1
+
+        return 0
+
+    except Exception as e:
+        print(f"Run error: {e}", file=sys.stderr)
+        return 1
+
+
+def _handle_list(args) -> int:
+    """List: show devices and sink status."""
+    try:
+        cfg = load_config(args.config)
+        pa = PulseAudio()
+        store = StateStore()
+        provisioner = Provisioner(pa, cfg.sink, store)
+
+        status = provisioner.status()
+
+        # Print input devices
+        print("\n=== Input Devices ===")
+        devices = pa.list_devices(exclude=_own_device_names(cfg))
+        input_devices = [d for d in devices if d.kind == "input"]
+        if input_devices:
+            for device in input_devices:
+                print(f"  {device.name}")
+                print(f"    {device.description}")
+        else:
+            print("  (none)")
+
+        # Print output devices
+        print("\n=== Output Devices ===")
+        output_devices = [d for d in devices if d.kind == "output"]
+        if output_devices:
+            for device in output_devices:
+                print(f"  {device.name}")
+                print(f"    {device.description}")
+        else:
+            print("  (none)")
+
+        # Print program sink status
+        print("\n=== Program Sink Status ===")
+        print(f"  State file: {status['state_file']}")
+
+        state = status["state"]
+        if state:
+            print(f"  Sink: {state.sink_name}")
+            print(f"  Present: {'Yes' if status['sink_present'] else 'No'}")
+            print("  Modules tracked:")
+            print(f"    {state.null_sink_module} (null-sink)")
+            print(f"    {state.remap_module} (remap-source)")
+            for lb in state.loopbacks:
+                print(f"    {lb.module_id} (loopback: {lb.source})")
+            if status["live_modules"]:
+                num_live = len(status["live_modules"])
+                num_total = 2 + len(state.loopbacks)
+                print(f"  Live modules: {num_live} of {num_total}")
+        else:
+            print("  (no saved state)")
+
+        return 0
+
+    except Exception as e:
+        print(f"List error: {e}", file=sys.stderr)
+        return 1
+
+
+def _handle_clear(args) -> int:
+    """Clear: remove virtual sink and loopbacks."""
+    try:
+        if not args.yes:
+            response = input("Remove virtual sink and loopbacks? (y/N) ")
+            if response.lower() != "y":
+                print("Cancelled.")
+                return 0
+
+        cfg = load_config(args.config)
+        pa = PulseAudio()
+        store = StateStore()
+        provisioner = Provisioner(pa, cfg.sink, store)
+
+        status = provisioner.status()
+        state = status["state"]
+
+        provisioner.teardown()
+
+        print("✓ Removed virtual sink and loopbacks")
+        if state:
+            print("  Modules unloaded:")
+            print(f"    {state.null_sink_module} (null-sink)")
+            print(f"    {state.remap_module} (remap-source)")
+            for lb in state.loopbacks:
+                print(f"    {lb.module_id} (loopback: {lb.source})")
+
+        return 0
+
+    except Exception as e:
+        print(f"Clear error: {e}", file=sys.stderr)
+        return 1
+
+
+def _check(args) -> int:
+    """Test audio capture and transcriber (legacy --check or run --check)."""
+    cfg = load_config(args.config)
+
+    if args.device:
+        cfg.audio.device = args.device
+    if args.backend:
+        cfg.audio.backend = args.backend
+    if args.model:
+        cfg.whisper.model = args.model
+
+    print("Checking audio capture...", file=sys.stderr, flush=True)
+
+    try:
+        capture = open_capture(cfg.audio)
+    except Exception as e:
+        print(f"Error opening capture: {e}", file=sys.stderr)
+        return 1
+
+    try:
+        frame_count = 0
+        frames = []
+
+        print(f"Capturing for ~3 seconds (frame_bytes={capture.frame_bytes})...",
+              file=sys.stderr, flush=True)
+
+        for frame in capture.frames():
+            frames.append(frame)
+            frame_count += 1
+            if frame_count >= 100:  # ~3 seconds at 30ms frames
+                break
+
+        capture.close()
+
+        # Compute RMS
+        raw = b"".join(frames)
+        audio = np.frombuffer(raw, dtype=np.int16).astype(np.float32)
+        rms = float(np.sqrt(np.mean(audio**2)))
+
+        print(f"✓ Captured {frame_count} frames, RMS={rms:.1f}", file=sys.stderr, flush=True)
+
+    except Exception as e:
+        print(f"Error during capture: {e}", file=sys.stderr)
+        return 1
+
+    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,
+        )
+        transcriber.close()
+        print("✓ Model ready", flush=True)
+    except Exception as e:
+        print(f"Error loading model: {e}", file=sys.stderr)
+        return 1
+
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 154 - 0
src/stt_daemon/config.py

@@ -0,0 +1,154 @@
+"""Configuration management for STT daemon."""
+
+from dataclasses import dataclass, field
+import tomllib
+from pathlib import Path
+
+
+@dataclass
+class AudioConfig:
+    """Audio capture configuration."""
+    backend: str = "parec"
+    device: str = "VirtualMicSink.monitor"
+    sample_rate: int = 16000
+    channels: int = 1
+    frame_ms: int = 30
+    gain: float = 1.0
+
+
+@dataclass
+class VadConfig:
+    """Voice Activity Detection configuration."""
+    aggressiveness: int = 2
+    pre_roll_ms: int = 400
+    speech_start_ms: int = 90
+    silence_ms: int = 700
+    min_utterance_ms: int = 300
+    max_utterance_s: float = 30.0
+
+
+@dataclass
+class WhisperConfig:
+    """Whisper transcription configuration."""
+    model: str = "small"
+    device: str = "cpu"
+    compute_type: str = "int8"
+    language: str | None = None
+    beam_size: int = 1
+    vad_filter: bool = False
+
+
+@dataclass
+class OutputConfig:
+    """Output configuration."""
+    stdout: bool = True
+    jsonl_dir: str | None = "transcripts"
+
+
+@dataclass
+class SinkConfig:
+    """Virtual audio sink configuration."""
+    sink_name: str = "VirtualMicSink"
+    remap_source_name: str = "VirtualMic"
+    loopback_latency_msec: int = 1
+    monitor_output: bool = False
+
+
+@dataclass
+class Config:
+    """Complete daemon configuration."""
+    audio: AudioConfig = field(default_factory=AudioConfig)
+    vad: VadConfig = field(default_factory=VadConfig)
+    whisper: WhisperConfig = field(default_factory=WhisperConfig)
+    output: OutputConfig = field(default_factory=OutputConfig)
+    sink: SinkConfig = field(default_factory=SinkConfig)
+
+
+def load_config(path: str | None = None) -> Config:
+    """Load configuration from TOML file, with defaults.
+
+    Args:
+        path: Path to config.toml. If None, looks for config.toml in cwd,
+              then uses all defaults.
+
+    Returns:
+        Config with loaded values overlayed on defaults.
+    """
+    cfg = Config()
+
+    # Try to find and load config file
+    config_path = None
+    if path:
+        config_path = Path(path)
+    else:
+        default_path = Path("config.toml")
+        if default_path.exists():
+            config_path = default_path
+
+    if config_path and config_path.exists():
+        with open(config_path, "rb") as f:
+            data = tomllib.load(f)
+
+        # Audio section
+        if "audio" in data:
+            audio_data = data["audio"]
+            cfg.audio = AudioConfig(
+                backend=audio_data.get("backend", cfg.audio.backend),
+                device=audio_data.get("device", cfg.audio.device),
+                sample_rate=audio_data.get("sample_rate", cfg.audio.sample_rate),
+                channels=audio_data.get("channels", cfg.audio.channels),
+                frame_ms=audio_data.get("frame_ms", cfg.audio.frame_ms),
+                gain=audio_data.get("gain", cfg.audio.gain),
+            )
+
+        # VAD section
+        if "vad" in data:
+            vad_data = data["vad"]
+            cfg.vad = VadConfig(
+                aggressiveness=vad_data.get("aggressiveness", cfg.vad.aggressiveness),
+                pre_roll_ms=vad_data.get("pre_roll_ms", cfg.vad.pre_roll_ms),
+                speech_start_ms=vad_data.get("speech_start_ms", cfg.vad.speech_start_ms),
+                silence_ms=vad_data.get("silence_ms", cfg.vad.silence_ms),
+                min_utterance_ms=vad_data.get("min_utterance_ms", cfg.vad.min_utterance_ms),
+                max_utterance_s=vad_data.get("max_utterance_s", cfg.vad.max_utterance_s),
+            )
+
+        # Whisper section
+        if "whisper" in data:
+            whisper_data = data["whisper"]
+            language = whisper_data.get("language", cfg.whisper.language)
+            # Normalize language: "", "auto", "none" -> None
+            if language in ("", "auto", "none"):
+                language = None
+            cfg.whisper = WhisperConfig(
+                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),
+                language=language,
+                beam_size=whisper_data.get("beam_size", cfg.whisper.beam_size),
+                vad_filter=whisper_data.get("vad_filter", cfg.whisper.vad_filter),
+            )
+
+        # Output section
+        if "output" in data:
+            output_data = data["output"]
+            cfg.output = OutputConfig(
+                stdout=output_data.get("stdout", cfg.output.stdout),
+                jsonl_dir=output_data.get("jsonl_dir", cfg.output.jsonl_dir),
+            )
+
+        # Sink section
+        if "sink" in data:
+            sink_data = data["sink"]
+            cfg.sink = SinkConfig(
+                sink_name=sink_data.get("sink_name", cfg.sink.sink_name),
+                remap_source_name=sink_data.get(
+                    "remap_source_name", cfg.sink.remap_source_name
+                ),
+                loopback_latency_msec=sink_data.get(
+                    "loopback_latency_msec", cfg.sink.loopback_latency_msec
+                ),
+                monitor_output=sink_data.get("monitor_output", cfg.sink.monitor_output),
+            )
+
+    return cfg

+ 185 - 0
src/stt_daemon/daemon.py

@@ -0,0 +1,185 @@
+"""Main daemon that orchestrates capture, segmentation, and transcription."""
+
+import logging
+import queue
+import threading
+from typing import Optional
+
+from .audio import AudioCapture, open_capture
+from .config import Config
+from .output import TranscriptSink
+from .segmenter import FrameSegmenter, make_vad
+from .transcriber import Transcriber
+
+
+class Daemon:
+    """Always-on STT daemon with worker threads."""
+
+    def __init__(self, cfg: Config, log_level: int = logging.INFO):
+        """Initialize the daemon.
+
+        Args:
+            cfg: Configuration object.
+            log_level: Logging level (e.g., logging.INFO, logging.DEBUG).
+        """
+        self.cfg = cfg
+
+        # Set up logging
+        self.logger = logging.getLogger("stt_daemon")
+        self.logger.setLevel(log_level)
+        if not self.logger.handlers:
+            handler = logging.StreamHandler()
+            handler.setFormatter(logging.Formatter("[%(levelname)s] %(message)s"))
+            self.logger.addHandler(handler)
+
+        # Initialize components
+        self.capture: Optional[AudioCapture] = None
+        self.segmenter: Optional[FrameSegmenter] = None
+        self.transcriber: Optional[Transcriber] = None
+        self.sink: Optional[TranscriptSink] = None
+
+        # Threading
+        self.segment_queue: queue.Queue = queue.Queue(maxsize=50)
+        self._stop_event = threading.Event()
+        self._stopped = False
+        self._reader_thread: Optional[threading.Thread] = None
+        self._worker_thread: Optional[threading.Thread] = None
+
+    def run(self) -> None:
+        """Run the daemon (blocks until stopped)."""
+        try:
+            self._initialize()
+            self._start_workers()
+            self._stop_event.wait()
+        finally:
+            self.stop()
+
+    def _initialize(self) -> None:
+        """Initialize all components."""
+        self.logger.info("Initializing audio capture...")
+        self.capture = open_capture(self.cfg.audio)
+
+        self.logger.info("Initializing VAD...")
+        vad = make_vad(self.cfg.vad.aggressiveness)
+
+        self.logger.info("Initializing segmenter...")
+        self.segmenter = FrameSegmenter(
+            sample_rate=self.cfg.audio.sample_rate,
+            frame_ms=self.cfg.audio.frame_ms,
+            vad=vad,
+            pre_roll_ms=self.cfg.vad.pre_roll_ms,
+            speech_start_ms=self.cfg.vad.speech_start_ms,
+            silence_ms=self.cfg.vad.silence_ms,
+            min_utterance_ms=self.cfg.vad.min_utterance_ms,
+            max_utterance_s=self.cfg.vad.max_utterance_s,
+        )
+
+        self.logger.info(f"Loading Whisper model '{self.cfg.whisper.model}'...")
+        self.transcriber = 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,
+        )
+
+        self.logger.info("Initializing output sink...")
+        self.sink = TranscriptSink(
+            stdout=self.cfg.output.stdout,
+            jsonl_dir=self.cfg.output.jsonl_dir,
+        )
+
+        self.logger.info("Daemon ready. Listening...")
+
+    def _start_workers(self) -> None:
+        """Start reader and worker threads."""
+        self._reader_thread = threading.Thread(target=self._reader_loop, daemon=False)
+        self._worker_thread = threading.Thread(target=self._worker_loop, daemon=False)
+        self._reader_thread.start()
+        self._worker_thread.start()
+
+    def _reader_loop(self) -> None:
+        """Reader thread: capture frames and segment them."""
+        try:
+            if not self.capture or not self.segmenter:
+                return
+
+            for frame in self.capture.frames():
+                if self._stop_event.is_set():
+                    break
+
+                utt = self.segmenter.push(frame)
+                if utt:
+                    try:
+                        self.segment_queue.put(utt, block=False)
+                    except queue.Full:
+                        self.logger.warning("Segment queue full, dropping oldest")
+                        try:
+                            self.segment_queue.get_nowait()
+                            self.segment_queue.put(utt, block=False)
+                        except queue.Empty:
+                            pass
+
+            # Shutdown: flush any in-progress utterance
+            if self.segmenter:
+                utt = self.segmenter.flush()
+                if utt:
+                    try:
+                        self.segment_queue.put(utt, block=False)
+                    except queue.Full:
+                        pass
+
+        except Exception as e:
+            if self._stop_event.is_set():
+                self.logger.debug(f"Reader stopped: {e}")
+            else:
+                self.logger.error(f"Reader thread error: {e}")
+        finally:
+            if self.capture:
+                self.capture.close()
+
+    def _worker_loop(self) -> None:
+        """Worker thread: transcribe segments and emit results."""
+        try:
+            while not self._stop_event.is_set():
+                try:
+                    utt = self.segment_queue.get(timeout=1)
+                except queue.Empty:
+                    continue
+
+                if self.transcriber and self.sink:
+                    try:
+                        result = self.transcriber.transcribe(utt.audio)
+                        self.sink.emit(result, utt.duration_s)
+                    except Exception as e:
+                        self.logger.error(f"Transcription error: {e}")
+
+        except Exception as e:
+            self.logger.error(f"Worker thread error: {e}")
+
+    def stop(self) -> None:
+        """Stop the daemon. Safe to call more than once."""
+        if self._stopped:
+            return
+        self._stopped = True
+
+        self.logger.info("Stopping daemon...")
+        self._stop_event.set()
+
+        # Close capture FIRST so the reader thread's blocking read unblocks
+        # (otherwise a non-daemon reader thread can hang process shutdown).
+        if self.capture:
+            self.capture.close()
+
+        if self._reader_thread:
+            self._reader_thread.join(timeout=5)
+        if self._worker_thread:
+            self._worker_thread.join(timeout=5)
+
+        if self.transcriber:
+            self.transcriber.close()
+        if self.sink:
+            self.sink.close()
+
+        self.logger.info("Daemon stopped.")

+ 60 - 0
src/stt_daemon/output.py

@@ -0,0 +1,60 @@
+"""Output sinks for transcripts."""
+
+import json
+from datetime import datetime
+from pathlib import Path
+
+from .transcriber import TranscriptResult
+
+
+class TranscriptSink:
+    """Emits transcripts to stdout and/or JSONL files."""
+
+    def __init__(self, stdout: bool = True, jsonl_dir: str | None = None):
+        """Initialize output sinks.
+
+        Args:
+            stdout: If True, emit to stdout.
+            jsonl_dir: If set, append JSONL lines to daily files in this directory.
+        """
+        self.stdout = stdout
+        self.jsonl_dir = Path(jsonl_dir) if jsonl_dir else None
+
+    def emit(self, result: TranscriptResult, duration_s: float) -> None:
+        """Emit a transcript result.
+
+        Args:
+            result: TranscriptResult from transcriber.
+            duration_s: Duration of the utterance in seconds.
+        """
+        if not result.text:
+            return
+
+        # Stdout line: [HH:MM:SS] (lang) text
+        if self.stdout:
+            now = datetime.now()
+            timestamp = now.strftime("%H:%M:%S")
+            line = f"[{timestamp}] ({result.language}) {result.text}"
+            print(line, flush=True)
+
+        # JSONL file per day
+        if self.jsonl_dir:
+            self.jsonl_dir.mkdir(parents=True, exist_ok=True)
+            today = datetime.now().strftime("%Y-%m-%d")
+            jsonl_path = self.jsonl_dir / f"{today}.jsonl"
+
+            record = {
+                "ts": datetime.now().isoformat(),
+                "lang": result.language,
+                "lang_prob": result.language_probability,
+                "duration_s": duration_s,
+                "text": result.text,
+            }
+
+            with open(jsonl_path, "a") as f:
+                json.dump(record, f, ensure_ascii=False)
+                f.write("\n")
+
+    def close(self) -> None:
+        """Clean up (no-op for file-based output)."""
+        pass

+ 142 - 0
src/stt_daemon/picker.py

@@ -0,0 +1,142 @@
+"""Interactive device picker using questionary or CLI fallback."""
+
+import sys
+from typing import Iterable
+
+from .pulse import Device
+
+
+def _build_choices(devices: list[Device], preselected: set[str]) -> list:
+    """Build questionary checkbox choices.
+
+    questionary preselects via ``Choice(..., checked=True)``; passing a
+    ``default`` list to ``checkbox`` is rejected by its validator.
+    """
+    import questionary
+
+    return [
+        questionary.Choice(
+            f"[{'OUT' if d.kind == 'output' else 'IN'}] {d.description}  ({d.name})",
+            value=d.name,
+            checked=d.name in preselected,
+        )
+        for d in devices
+    ]
+
+
+def select_sources(devices: list[Device], preselected: Iterable[str] = ()) -> list[str]:
+    """Interactively select audio sources.
+
+    If questionary is available and stdin is a TTY, show a checkbox UI.
+    Otherwise, show a numbered menu.
+
+    Args:
+        devices: List of available Device objects.
+        preselected: Source names that should be pre-checked.
+
+    Returns:
+        List of selected source names in device order, deduplicated.
+
+    Raises:
+        RuntimeError: If non-TTY and user provides invalid input with no valid context.
+    """
+    preselected_set = set(preselected)
+
+    # Try questionary UI first
+    if sys.stdin.isatty():
+        try:
+            import questionary
+        except ImportError:
+            questionary = None
+
+        if questionary is not None:
+            try:
+                selected = questionary.checkbox(
+                    "Select audio sources to capture:",
+                    choices=_build_choices(devices, preselected_set),
+                ).ask()
+            except KeyboardInterrupt:
+                return list(preselected)
+
+            if selected is None:
+                # User aborted (Ctrl+C)
+                return list(preselected)
+
+            # Preserve device order
+            result = []
+            seen = set()
+            for device in devices:
+                if device.name in selected and device.name not in seen:
+                    result.append(device.name)
+                    seen.add(device.name)
+            return result
+
+    # CLI fallback: show numbered menu and read input
+    print("\nAvailable audio sources:")
+    for i, device in enumerate(devices, 1):
+        kind_label = "OUT" if device.kind == "output" else "IN"
+        marker = "✓" if device.name in preselected_set else " "
+        print(f"  {marker} {i:2}. [{kind_label}] {device.description}")
+
+    while True:
+        prompt = (
+            "\nSelect sources by number (space/comma-separated), "
+            "'all', 'none', or blank to keep current:"
+        )
+        print(prompt)
+        try:
+            line = input("> ").strip()
+        except EOFError:
+            raise RuntimeError(
+                "No TTY and EOF reached. Please provide --sources with comma-separated names "
+                "(e.g. --sources 'alsa_input.usb-...,alsa_output.usb-...')"
+            )
+
+        if not line:
+            # Keep preselected
+            result = []
+            seen = set()
+            for device in devices:
+                if device.name in preselected_set and device.name not in seen:
+                    result.append(device.name)
+                    seen.add(device.name)
+            return result
+
+        if line.lower() == "all":
+            return [d.name for d in devices]
+
+        if line.lower() == "none":
+            return []
+
+        # Parse indices
+        try:
+            # Accept space or comma as separator
+            indices_str = line.replace(",", " ")
+            indices = [int(x) for x in indices_str.split()]
+
+            # Validate all indices first
+            selected_indices = set()
+            for idx in indices:
+                if 1 <= idx <= len(devices):
+                    selected_indices.add(idx)
+                else:
+                    print(
+                        f"Invalid index: {idx}. "
+                        f"Please enter numbers between 1 and {len(devices)}."
+                    )
+                    break
+            else:
+                # All indices were valid; return devices in device list order
+                result = []
+                seen = set()
+                for i, device in enumerate(devices, 1):
+                    if i in selected_indices and device.name not in seen:
+                        result.append(device.name)
+                        seen.add(device.name)
+                return result
+
+        except ValueError:
+            print(
+                "Invalid input. Please enter space/comma-separated numbers, "
+                "'all', 'none', or blank."
+            )

+ 186 - 0
src/stt_daemon/provisioner.py

@@ -0,0 +1,186 @@
+"""Orchestrates creation and teardown of virtual audio sink and loopback modules."""
+
+from .config import SinkConfig
+from .pulse import PulseAudio
+from .state import AudioState, Loopback, StateStore
+
+
+class Provisioner:
+    """Manages virtual sink provisioning and persistence."""
+
+    def __init__(self, pa: PulseAudio, cfg: SinkConfig, store: StateStore):
+        """Initialize the provisioner.
+
+        Args:
+            pa: PulseAudio interface.
+            cfg: SinkConfig with sink names and parameters.
+            store: StateStore for persistence.
+        """
+        self.pa = pa
+        self.cfg = cfg
+        self.store = store
+
+    def setup(self, sources: list[str]) -> AudioState:
+        """Create a virtual sink with loopbacks to specified sources.
+
+        Steps:
+        1. Teardown any existing tracked modules
+        2. Load module-null-sink
+        3. Load module-remap-source (expose monitor as microphone)
+        4. For each source: load module-loopback
+        5. Save and return state
+
+        Args:
+            sources: List of source names to route into the sink.
+
+        Returns:
+            AudioState with all created module IDs.
+
+        Raises:
+            RuntimeError: If sink name already exists and is not tracked.
+        """
+        # Check for conflicting untracked sink
+        if self.pa.sink_exists(self.cfg.sink_name):
+            existing_state = self.store.load()
+            if existing_state is None or existing_state.sink_name != self.cfg.sink_name:
+                raise RuntimeError(
+                    f"Sink '{self.cfg.sink_name}' already exists but is not tracked. "
+                    "Remove it manually or use a different sink_name."
+                )
+
+        # Teardown existing modules
+        try:
+            self.teardown()
+        except Exception:
+            pass  # Ignore failures, just proceeding
+
+        # Load null sink
+        null_sink_id = self.pa.load_module(
+            "module-null-sink",
+            f"sink_name={self.cfg.sink_name}",
+            f"sink_properties=device.description={self.cfg.sink_name}",
+        )
+
+        # Load remap source to expose the monitor as a microphone
+        remap_id = self.pa.load_module(
+            "module-remap-source",
+            f"master={self.cfg.sink_name}.monitor",
+            f"source_name={self.cfg.remap_source_name}",
+            f"source_properties=device.description={self.cfg.remap_source_name}",
+        )
+
+        # Load loopbacks for each selected source
+        loopbacks = []
+        for source_name in sources:
+            loopback_id = self.pa.load_module(
+                "module-loopback",
+                f"source={source_name}",
+                f"sink={self.cfg.sink_name}",
+                f"latency_msec={self.cfg.loopback_latency_msec}",
+            )
+            loopbacks.append(Loopback(module_id=loopback_id, source=source_name))
+
+        # Optionally loop the sink output back to the default sink (for monitoring).
+        # Track it in `loopbacks` so teardown can unload it (avoid a leaked module).
+        if self.cfg.monitor_output:
+            default_sink = self.pa.default_sink_name()
+            monitor_id = self.pa.load_module(
+                "module-loopback",
+                f"source={self.cfg.sink_name}.monitor",
+                f"sink={default_sink}",
+                f"latency_msec={self.cfg.loopback_latency_msec}",
+            )
+            loopbacks.append(
+                Loopback(module_id=monitor_id, source=f"{self.cfg.sink_name}.monitor")
+            )
+
+        # Create and persist state
+        state = AudioState(
+            sink_name=self.cfg.sink_name,
+            remap_name=self.cfg.remap_source_name,
+            null_sink_module=null_sink_id,
+            remap_module=remap_id,
+            loopbacks=loopbacks,
+            selected_sources=sources,
+        )
+
+        self.store.save(state)
+        return state
+
+    def teardown(self) -> None:
+        """Unload all tracked modules and clear state.
+
+        Unloads in reverse creation order (loopbacks, then remap, then null-sink).
+        Ignores modules that are already gone. Always clears the state file.
+        """
+        state = self.store.load()
+        if not state:
+            self.store.clear()
+            return
+
+        # Unload loopbacks in reverse order
+        for loopback in reversed(state.loopbacks):
+            try:
+                self.pa.unload_module(loopback.module_id)
+            except RuntimeError:
+                pass  # Already gone is fine
+
+        # Unload remap source
+        try:
+            self.pa.unload_module(state.remap_module)
+        except RuntimeError:
+            pass
+
+        # Unload null sink
+        try:
+            self.pa.unload_module(state.null_sink_module)
+        except RuntimeError:
+            pass
+
+        # Always clear state
+        self.store.clear()
+
+    def ensure(self) -> AudioState | None:
+        """Ensure provisioning exists; re-provision if sink is missing.
+
+        Returns:
+            AudioState if provisioning is active, None otherwise.
+        """
+        state = self.store.load()
+        if not state:
+            return None
+
+        # If sink exists, we're good
+        if self.pa.sink_exists(state.sink_name):
+            return state
+
+        # Sink is missing; re-provision from saved source list
+        return self.setup(state.selected_sources)
+
+    def status(self) -> dict:
+        """Get status for `list` command.
+
+        Returns:
+            Dict with state, sink presence, and live module IDs.
+        """
+        state = self.store.load()
+        sink_present = self.pa.sink_exists(self.cfg.sink_name) if state else False
+
+        live_modules = []
+        if state:
+            short_modules = self.pa.short_modules()
+            module_ids = {
+                state.null_sink_module,
+                state.remap_module,
+                *[lb.module_id for lb in state.loopbacks],
+            }
+            for mod_id, mod_name in short_modules:
+                if mod_id in module_ids:
+                    live_modules.append((mod_id, mod_name))
+
+        return {
+            "state": state,
+            "sink_present": sink_present,
+            "live_modules": live_modules,
+            "state_file": str(self.store.path),
+        }

+ 231 - 0
src/stt_daemon/pulse.py

@@ -0,0 +1,231 @@
+"""PulseAudio access with injectable command runner for testability."""
+
+import json
+import subprocess
+from dataclasses import dataclass
+from typing import Callable
+
+
+@dataclass(frozen=True)
+class Device:
+    """Represents a capturable audio device (input or output monitor)."""
+    name: str               # Source name to CAPTURE
+    description: str        # Human-readable label
+    kind: str              # "input" | "output"
+
+
+def _json_list(output: str, key: str) -> list[dict]:
+    """Parse `pactl -f json list <x>` output.
+
+    Real pactl emits a top-level JSON array; some versions/older patches wrap it
+    in an object under the plural key. Tolerate both.
+    """
+    data = json.loads(output)
+    if isinstance(data, list):
+        return data
+    if isinstance(data, dict):
+        return data.get(key, [])
+    return []
+
+
+def _is_monitor_source(source: dict) -> bool:
+    """A monitor source mirrors a sink.
+
+    Real `pactl -f json list sources` marks these with a non-empty
+    `monitor_source` (the sink name); real inputs have it empty. Fall back to
+    the `.monitor` name convention for safety.
+    """
+    if source.get("monitor_source"):
+        return True
+    if source.get("monitor_of_sink") is not None:
+        return True
+    return source.get("name", "").endswith(".monitor")
+
+
+def run_pactl(args: list[str]) -> str:
+    """Run a pactl command and return stdout.
+
+    Args:
+        args: Command arguments (e.g., ["list", "short", "modules"]).
+
+    Returns:
+        stdout as string.
+
+    Raises:
+        RuntimeError: If pactl fails.
+    """
+    try:
+        result = subprocess.run(
+            ["pactl"] + args,
+            capture_output=True,
+            text=True,
+            check=True,
+        )
+        return result.stdout
+    except subprocess.CalledProcessError as e:
+        raise RuntimeError(f"pactl failed: {e.stderr}") from e
+    except FileNotFoundError as e:
+        raise RuntimeError("pactl not found. Install PulseAudio or PipeWire-Pulse.") from e
+
+
+class PulseAudio:
+    """Interface to PulseAudio with injectable runner for testing."""
+
+    def __init__(self, runner: Callable[[list[str]], str] | None = None):
+        """Initialize PulseAudio interface.
+
+        Args:
+            runner: Function that executes pactl args and returns stdout.
+                   If None, uses the default run_pactl function.
+        """
+        self.runner = runner or run_pactl
+
+    def list_sources(self) -> list[dict]:
+        """Get all PulseAudio sources as dictionaries.
+
+        Returns:
+            List of source dicts from pactl -f json list sources.
+        """
+        return _json_list(self.runner(["-f", "json", "list", "sources"]), "sources")
+
+    def list_sinks(self) -> list[dict]:
+        """Get all PulseAudio sinks as dictionaries.
+
+        Returns:
+            List of sink dicts from pactl -f json list sinks.
+        """
+        return _json_list(self.runner(["-f", "json", "list", "sinks"]), "sinks")
+
+    def list_devices(self, exclude: set[str] | None = None) -> list[Device]:
+        """List all capturable devices (inputs and output monitors).
+
+        Args:
+            exclude: Source/sink names to omit (e.g. our own virtual devices).
+
+        Returns:
+            List of Device objects. Inputs are non-monitor sources; outputs are
+            sinks, captured via their monitor_source.
+        """
+        excluded = exclude or set()
+        devices: list[Device] = []
+
+        for source in self.list_sources():
+            name = source["name"]
+            if _is_monitor_source(source) or name in excluded:
+                continue
+            devices.append(Device(
+                name=name,
+                description=source.get("description", name),
+                kind="input",
+            ))
+
+        for sink in self.list_sinks():
+            name = sink["name"]
+            monitor_source = sink.get("monitor_source")
+            if not monitor_source or name in excluded or monitor_source in excluded:
+                continue
+            devices.append(Device(
+                name=monitor_source,
+                description=sink.get("description", name),
+                kind="output",
+            ))
+
+        return devices
+
+    def sink_exists(self, name: str) -> bool:
+        """Check if a sink with the given name exists.
+
+        Args:
+            name: Sink name to check.
+
+        Returns:
+            True if sink exists, False otherwise.
+        """
+        sinks = self.list_sinks()
+        return any(s["name"] == name for s in sinks)
+
+    def source_exists(self, name: str) -> bool:
+        """Check if a source with the given name exists.
+
+        Args:
+            name: Source name to check.
+
+        Returns:
+            True if source exists, False otherwise.
+        """
+        sources = self.list_sources()
+        return any(s["name"] == name for s in sources)
+
+    def default_sink_name(self) -> str:
+        """Get the default sink name.
+
+        Returns:
+            Name of the default sink.
+
+        Raises:
+            RuntimeError: If unable to get default sink.
+        """
+        output = self.runner(["get-default-sink"])
+        return output.strip()
+
+    def load_module(self, module: str, *args: str) -> int:
+        """Load a PulseAudio module and return its numeric ID.
+
+        Args:
+            module: Module name (e.g., "module-null-sink").
+            *args: Module arguments (e.g., "sink_name=VirtualMicSink").
+
+        Returns:
+            Numeric module ID.
+
+        Raises:
+            RuntimeError: If module fails to load.
+        """
+        pactl_args = ["load-module", module] + list(args)
+        output = self.runner(pactl_args)
+        try:
+            return int(output.strip())
+        except ValueError as e:
+            raise RuntimeError(f"Failed to parse module ID from: {output}") from e
+
+    def unload_module(self, module_id: int) -> bool:
+        """Unload a PulseAudio module by ID.
+
+        Args:
+            module_id: Numeric module ID.
+
+        Returns:
+            True if unloaded, False if module was already gone.
+
+        Raises:
+            RuntimeError: On other errors.
+        """
+        try:
+            self.runner(["unload-module", str(module_id)])
+            return True
+        except RuntimeError as e:
+            message = str(e).lower()
+            if "no such" in message or "not found" in message or "entity" in message:
+                return False
+            raise
+
+    def short_modules(self) -> list[tuple[int, str]]:
+        """Get list of loaded modules as (id, name) pairs.
+
+        Returns:
+            List of (module_id, module_name) tuples.
+        """
+        output = self.runner(["list", "short", "modules"])
+        modules = []
+        for line in output.strip().split("\n"):
+            if not line:
+                continue
+            parts = line.split("\t")
+            if len(parts) >= 2:
+                try:
+                    mod_id = int(parts[0])
+                    mod_name = parts[1]
+                    modules.append((mod_id, mod_name))
+                except ValueError:
+                    pass
+        return modules

+ 185 - 0
src/stt_daemon/segmenter.py

@@ -0,0 +1,185 @@
+"""Voice activity detection and utterance segmentation with pre-roll buffer."""
+
+import collections
+import time
+from dataclasses import dataclass
+
+import numpy as np
+import webrtcvad
+
+
+@dataclass
+class Utterance:
+    """A complete utterance ready for transcription."""
+    audio: np.ndarray      # float32, mono, normalized to [-1, 1], 16 kHz
+    duration_s: float
+    started_at: float      # time.time() when confirmed
+
+
+def make_vad(aggressiveness: int) -> webrtcvad.Vad:
+    """Create a VAD instance with validation.
+
+    Args:
+        aggressiveness: Aggressiveness level 0-3 (higher = fewer false positives).
+
+    Returns:
+        Initialized webrtcvad.Vad instance.
+
+    Raises:
+        ValueError: If aggressiveness is not in 0-3.
+    """
+    if aggressiveness not in (0, 1, 2, 3):
+        raise ValueError(
+            f"aggressiveness must be 0-3, got {aggressiveness}"
+        )
+    vad = webrtcvad.Vad(aggressiveness)
+    return vad
+
+
+class FrameSegmenter:
+    """Segments audio frames into utterances using VAD with pre-roll buffer.
+
+    The segmenter maintains a ring buffer of pre-speech frames so that when
+    speech is confirmed, the beginning of the utterance (pre-roll) is preserved.
+    """
+
+    def __init__(
+        self,
+        sample_rate: int,
+        frame_ms: int,
+        vad: webrtcvad.Vad,
+        pre_roll_ms: int,
+        speech_start_ms: int,
+        silence_ms: int,
+        min_utterance_ms: int,
+        max_utterance_s: float,
+    ):
+        """Initialize the segmenter.
+
+        Args:
+            sample_rate: Sample rate in Hz (typically 16000).
+            frame_ms: Frame duration in milliseconds (10, 20, or 30 for webrtcvad).
+            vad: webrtcvad.Vad instance (already created).
+            pre_roll_ms: Duration of pre-roll buffer in ms.
+            speech_start_ms: Consecutive speech frames to confirm speech start.
+            silence_ms: Consecutive silence frames to end an utterance.
+            min_utterance_ms: Minimum utterance duration to keep (discard blips).
+            max_utterance_s: Maximum utterance duration before force-finalize.
+        """
+        self.sample_rate = sample_rate
+        self.frame_ms = frame_ms
+        self.vad = vad
+
+        # Frame-based counters and buffers
+        self.frame_bytes = round(sample_rate * frame_ms / 1000) * 2  # s16le = 2 bytes/sample
+        self.pre_roll_frames = max(1, round(pre_roll_ms / frame_ms))
+        self.start_frames = max(1, round(speech_start_ms / frame_ms))
+        self.silence_frames = max(1, round(silence_ms / frame_ms))
+        self.min_frames = max(1, round(min_utterance_ms / frame_ms))
+        self.max_frames = max(1, round(max_utterance_s * 1000 / frame_ms))
+
+        # Ring buffer for pre-speech frames
+        self.ring: collections.deque[bytes] = collections.deque(maxlen=self.pre_roll_frames)
+
+        # Segment assembly during speech
+        self.segment: list[bytes] = []
+
+        # State machine
+        self.speaking = False
+        self.speech_run = 0
+        self.silence_run = 0
+        self.speech_frames = 0
+        self.started_at = 0.0
+
+    def push(self, frame: bytes) -> Utterance | None:
+        """Process one audio frame and potentially emit an utterance.
+
+        Args:
+            frame: Raw audio frame (s16le PCM bytes, exactly frame_bytes long).
+
+        Returns:
+            Utterance if one is ready, None otherwise.
+        """
+        is_speech = self.vad.is_speech(frame, self.sample_rate)
+
+        if not self.speaking:
+            # Not yet speaking: accumulate pre-roll
+            self.ring.append(frame)
+            if is_speech:
+                self.speech_run += 1
+                if self.speech_run >= self.start_frames:
+                    # Speech confirmed
+                    return self._begin()
+            else:
+                self.speech_run = 0
+            return None
+
+        # Already speaking: accumulate segment
+        self.segment.append(frame)
+        if is_speech:
+            self.silence_run = 0
+            self.speech_frames += 1
+        else:
+            self.silence_run += 1
+
+        if self.silence_run >= self.silence_frames:
+            # Silence finalized
+            return self._finish(keep_tail=True)
+
+        if len(self.segment) >= self.max_frames:
+            # Max duration exceeded
+            return self._finish(keep_tail=False)
+
+        return None
+
+    def _begin(self) -> None:
+        """Called when speech is confirmed: initialize segment with pre-roll."""
+        self.speaking = True
+        self.segment = list(self.ring)
+        self.speech_frames = self.speech_run
+        self.speech_run = 0
+        self.silence_run = 0
+        self.started_at = time.time()
+        return None
+
+    def flush(self) -> Utterance | None:
+        """Finalize any in-progress utterance (called on shutdown)."""
+        if self.speaking:
+            return self._finish(keep_tail=False)
+        return None
+
+    def _finish(self, keep_tail: bool) -> Utterance | None:
+        """Finalize and emit an utterance."""
+        frames = self.segment
+
+        # Optionally keep trailing silence frames to seed next pre-roll
+        if keep_tail and self.silence_run > 0:
+            tail = frames[-self.silence_run :]
+        else:
+            tail = []
+
+        # Capture before reset: blips must be judged on actual speech frames,
+        # not on the segment length (which always includes the pre-roll padding).
+        speech_frames = self.speech_frames
+
+        # Reset state
+        self.speaking = False
+        self.segment = []
+        self.speech_run = 0
+        self.silence_run = 0
+        self.speech_frames = 0
+        self.ring = collections.deque(tail, maxlen=self.ring.maxlen)
+
+        # Discard blips
+        if speech_frames < self.min_frames:
+            return None
+
+        # Convert frames to audio
+        raw = b"".join(frames)
+        audio = np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0
+
+        return Utterance(
+            audio=audio,
+            duration_s=len(audio) / self.sample_rate,
+            started_at=self.started_at,
+        )

+ 103 - 0
src/stt_daemon/state.py

@@ -0,0 +1,103 @@
+"""Persistence of virtual audio sink state."""
+
+import json
+import os
+from dataclasses import asdict, dataclass, field
+from pathlib import Path
+
+
+@dataclass
+class Loopback:
+    """A loopback module that connects a source to the sink."""
+    module_id: int
+    source: str
+
+
+@dataclass
+class AudioState:
+    """Complete state of the provisioned virtual audio sink."""
+    sink_name: str
+    remap_name: str
+    null_sink_module: int
+    remap_module: int
+    loopbacks: list[Loopback] = field(default_factory=list)
+    selected_sources: list[str] = field(default_factory=list)
+
+
+class StateStore:
+    """Persist and restore AudioState from filesystem."""
+
+    def __init__(self, path: Path | None = None):
+        """Initialize the state store.
+
+        Args:
+            path: Path to the state file. If None, uses XDG_STATE_HOME or ~/.local/state.
+        """
+        if path is None:
+            # Try STT_DAEMON_STATE env var first
+            env_path = os.environ.get("STT_DAEMON_STATE")
+            if env_path:
+                path = Path(env_path)
+            else:
+                # XDG default: $XDG_STATE_HOME/stt-daemon/audio.json
+            # or ~/.local/state/stt-daemon/audio.json
+                state_home = os.environ.get("XDG_STATE_HOME")
+                if not state_home:
+                    home = os.environ.get("HOME", "~")
+                    state_home = str(Path(home) / ".local" / "state")
+                path = Path(state_home) / "stt-daemon" / "audio.json"
+
+        self.path = path
+
+    def load(self) -> AudioState | None:
+        """Load state from file.
+
+        Returns:
+            AudioState if file exists and is valid JSON, None otherwise.
+            Never raises; returns None on missing or corrupt files.
+        """
+        try:
+            if not self.path.exists():
+                return None
+
+            with open(self.path, "r") as f:
+                data = json.load(f)
+
+            # Reconstruct Loopback objects
+            loopbacks = [
+                Loopback(
+                    module_id=lb["module_id"],
+                    source=lb["source"],
+                )
+                for lb in data.get("loopbacks", [])
+            ]
+
+            return AudioState(
+                sink_name=data["sink_name"],
+                remap_name=data["remap_name"],
+                null_sink_module=data["null_sink_module"],
+                remap_module=data["remap_module"],
+                loopbacks=loopbacks,
+                selected_sources=data.get("selected_sources", []),
+            )
+        except (json.JSONDecodeError, KeyError, TypeError, OSError, ValueError):
+            return None
+
+    def save(self, state: AudioState) -> None:
+        """Save state to file.
+
+        Creates parent directories if needed.
+
+        Args:
+            state: AudioState to persist.
+        """
+        self.path.parent.mkdir(parents=True, exist_ok=True)
+
+        data = asdict(state)
+        with open(self.path, "w") as f:
+            json.dump(data, f, indent=2)
+
+    def clear(self) -> None:
+        """Remove the state file if it exists."""
+        if self.path.exists():
+            self.path.unlink()

+ 92 - 0
src/stt_daemon/transcriber.py

@@ -0,0 +1,92 @@
+"""Transcription using Faster-Whisper."""
+
+from dataclasses import dataclass
+
+import numpy as np
+from faster_whisper import WhisperModel
+
+
+@dataclass
+class TranscriptResult:
+    """Result of a transcription."""
+    language: str
+    language_probability: float
+    text: str
+
+
+class Transcriber:
+    """Wrapper around Faster-Whisper for transcription."""
+
+    def __init__(
+        self,
+        model: str,
+        device: str,
+        compute_type: str,
+        language: str | None,
+        beam_size: int = 1,
+        vad_filter: bool = False,
+    ):
+        """Initialize the transcriber.
+
+        Args:
+            model: Model name (e.g., "small", "base", "medium").
+            device: Device to use ("cpu", "cuda").
+            compute_type: Compute type ("int8" for cpu, "float16" for cuda).
+            language: Language code (e.g., "en", "es") or None for auto-detect.
+            beam_size: Beam search width (1 = greedy, fastest).
+            vad_filter: Enable Whisper's built-in VAD (we already run webrtcvad).
+        """
+        self.language = language
+        self.device = device
+        self.compute_type = compute_type
+        self.beam_size = beam_size
+        self.vad_filter = vad_filter
+
+        # Attempt CUDA if requested, fall back to CPU
+        if device == "cuda":
+            try:
+                import torch
+                if not torch.cuda.is_available():
+                    print(
+                        "CUDA requested but not available. Falling back to CPU.",
+                        flush=True,
+                    )
+                    self.device = "cpu"
+                    self.compute_type = "int8"
+            except ImportError:
+                print("torch not available. Using CPU.", flush=True)
+                self.device = "cpu"
+                self.compute_type = "int8"
+
+        self.model = WhisperModel(model, device=self.device, compute_type=self.compute_type)
+
+    def transcribe(self, audio: np.ndarray) -> TranscriptResult:
+        """Transcribe audio to text.
+
+        Args:
+            audio: Audio array (float32, mono, 16 kHz).
+
+        Returns:
+            TranscriptResult with language, probability, and text.
+        """
+        segments, info = self.model.transcribe(
+            audio,
+            language=self.language,
+            beam_size=self.beam_size,
+            vad_filter=self.vad_filter,
+            condition_on_previous_text=False,
+        )
+
+        # Join all segments
+        text = "".join(seg.text for seg in segments).strip()
+
+        return TranscriptResult(
+            language=info.language,
+            language_probability=info.language_probability,
+            text=text,
+        )
+
+    def close(self) -> None:
+        """Release the model."""
+        # Faster-whisper doesn't require explicit cleanup
+        pass

+ 1 - 0
tests/__init__.py

@@ -0,0 +1 @@
+"""Test suite."""

+ 142 - 0
tests/test_config.py

@@ -0,0 +1,142 @@
+"""Tests for configuration loading."""
+
+import tempfile
+from pathlib import Path
+
+from stt_daemon.config import load_config
+
+
+def test_load_config_defaults():
+    """Test that load_config returns defaults when no file exists."""
+    with tempfile.TemporaryDirectory() as tmpdir:
+        # Change to temp dir and load (no config.toml exists)
+        import os
+        old_cwd = os.getcwd()
+        try:
+            os.chdir(tmpdir)
+            cfg = load_config()
+            assert cfg.audio.backend == "parec"
+            assert cfg.audio.device == "VirtualMicSink.monitor"
+            assert cfg.audio.sample_rate == 16000
+            assert cfg.vad.aggressiveness == 2
+            assert cfg.whisper.model == "small"
+            assert cfg.whisper.language is None
+            assert cfg.output.stdout is True
+        finally:
+            os.chdir(old_cwd)
+
+
+def test_load_config_from_file():
+    """Test loading config from a TOML file."""
+    config_toml = """
+[audio]
+backend = "sounddevice"
+device = "hw:0"
+sample_rate = 44100
+
+[vad]
+aggressiveness = 3
+pre_roll_ms = 500
+
+[whisper]
+model = "base"
+device = "cuda"
+language = "es"
+
+[output]
+stdout = false
+jsonl_dir = "logs"
+"""
+    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.audio.backend == "sounddevice"
+        assert cfg.audio.device == "hw:0"
+        assert cfg.audio.sample_rate == 44100
+        assert cfg.vad.aggressiveness == 3
+        assert cfg.vad.pre_roll_ms == 500
+        assert cfg.whisper.model == "base"
+        assert cfg.whisper.device == "cuda"
+        assert cfg.whisper.language == "es"
+        assert cfg.output.stdout is False
+        assert cfg.output.jsonl_dir == "logs"
+
+
+def test_language_normalization_auto():
+    """Test that language 'auto' is normalized to None."""
+    config_toml = """
+[whisper]
+language = "auto"
+"""
+    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.language is None
+
+
+def test_language_normalization_empty_string():
+    """Test that language '' is normalized to None."""
+    config_toml = """
+[whisper]
+language = ""
+"""
+    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.language is None
+
+
+def test_language_normalization_none():
+    """Test that language 'none' is normalized to None."""
+    config_toml = """
+[whisper]
+language = "none"
+"""
+    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.language is None
+
+
+def test_language_explicit_en():
+    """Test that explicit language codes are preserved."""
+    config_toml = """
+[whisper]
+language = "en"
+"""
+    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.language == "en"
+
+
+def test_partial_config():
+    """Test that partial configs merge with defaults."""
+    config_toml = """
+[audio]
+device = "custom_device"
+"""
+    with tempfile.TemporaryDirectory() as tmpdir:
+        config_path = Path(tmpdir) / "config.toml"
+        config_path.write_text(config_toml)
+
+        cfg = load_config(str(config_path))
+
+        # Overridden
+        assert cfg.audio.device == "custom_device"
+
+        # Defaults
+        assert cfg.audio.backend == "parec"
+        assert cfg.audio.sample_rate == 16000
+        assert cfg.vad.aggressiveness == 2

+ 219 - 0
tests/test_picker.py

@@ -0,0 +1,219 @@
+"""Tests for interactive device picker (non-TTY fallback)."""
+
+import pytest
+
+from stt_daemon.picker import select_sources
+from stt_daemon.pulse import Device
+
+
+def test_select_sources_blank_keeps_preselected(monkeypatch, capsys):
+    """Test that blank input keeps preselected sources."""
+    devices = [
+        Device(name="alsa_input.usb-Foo", description="USB Mic", kind="input"),
+        Device(name="alsa_output.pci-0000.monitor", description="Speakers", kind="output"),
+    ]
+
+    monkeypatch.setattr("sys.stdin.isatty", lambda: False)
+    monkeypatch.setattr("builtins.input", lambda _: "")
+
+    result = select_sources(devices, preselected=["alsa_input.usb-Foo"])
+
+    assert result == ["alsa_input.usb-Foo"]
+
+
+def test_select_sources_all(monkeypatch, capsys):
+    """Test 'all' response."""
+    devices = [
+        Device(name="alsa_input.usb-Foo", description="USB Mic", kind="input"),
+        Device(name="alsa_output.pci-0000.monitor", description="Speakers", kind="output"),
+    ]
+
+    monkeypatch.setattr("sys.stdin.isatty", lambda: False)
+    monkeypatch.setattr("builtins.input", lambda _: "all")
+
+    result = select_sources(devices, preselected=[])
+
+    assert len(result) == 2
+    assert "alsa_input.usb-Foo" in result
+    assert "alsa_output.pci-0000.monitor" in result
+
+
+def test_select_sources_none(monkeypatch):
+    """Test 'none' response."""
+    devices = [
+        Device(name="alsa_input.usb-Foo", description="USB Mic", kind="input"),
+        Device(name="alsa_output.pci-0000.monitor", description="Speakers", kind="output"),
+    ]
+
+    monkeypatch.setattr("sys.stdin.isatty", lambda: False)
+    monkeypatch.setattr("builtins.input", lambda _: "none")
+
+    result = select_sources(devices, preselected=[])
+
+    assert result == []
+
+
+def test_select_sources_space_separated_indices(monkeypatch):
+    """Test space-separated numeric indices."""
+    devices = [
+        Device(name="alsa_input.usb-Foo", description="USB Mic", kind="input"),
+        Device(name="alsa_output.pci-0000.monitor", description="Speakers", kind="output"),
+    ]
+
+    monkeypatch.setattr("sys.stdin.isatty", lambda: False)
+    monkeypatch.setattr("builtins.input", lambda _: "1 2")
+
+    result = select_sources(devices, preselected=[])
+
+    assert len(result) == 2
+    assert result[0] == "alsa_input.usb-Foo"
+    assert result[1] == "alsa_output.pci-0000.monitor"
+
+
+def test_select_sources_comma_separated_indices(monkeypatch):
+    """Test comma-separated numeric indices."""
+    devices = [
+        Device(name="alsa_input.usb-Foo", description="USB Mic", kind="input"),
+        Device(name="alsa_output.pci-0000.monitor", description="Speakers", kind="output"),
+    ]
+
+    monkeypatch.setattr("sys.stdin.isatty", lambda: False)
+    monkeypatch.setattr("builtins.input", lambda _: "2,1")
+
+    result = select_sources(devices, preselected=[])
+
+    # Order preserved from devices list (not selection order)
+    assert len(result) == 2
+    assert set(result) == {"alsa_input.usb-Foo", "alsa_output.pci-0000.monitor"}
+
+
+def test_select_sources_deduplicates(monkeypatch):
+    """Test that duplicate indices are deduplicated."""
+    devices = [
+        Device(name="alsa_input.usb-Foo", description="USB Mic", kind="input"),
+        Device(name="alsa_output.pci-0000.monitor", description="Speakers", kind="output"),
+    ]
+
+    monkeypatch.setattr("sys.stdin.isatty", lambda: False)
+    monkeypatch.setattr("builtins.input", lambda _: "1 1 1")
+
+    result = select_sources(devices, preselected=[])
+
+    assert result == ["alsa_input.usb-Foo"]
+
+
+def test_select_sources_invalid_then_valid(monkeypatch):
+    """Test recovery from invalid input."""
+    devices = [
+        Device(name="alsa_input.usb-Foo", description="USB Mic", kind="input"),
+        Device(name="alsa_output.pci-0000.monitor", description="Speakers", kind="output"),
+    ]
+
+    inputs = iter(["invalid input", "all"])
+    monkeypatch.setattr("sys.stdin.isatty", lambda: False)
+    monkeypatch.setattr("builtins.input", lambda _: next(inputs))
+
+    result = select_sources(devices, preselected=[])
+
+    assert len(result) == 2
+
+
+def test_select_sources_eof_raises(monkeypatch):
+    """Test that EOF raises RuntimeError with helpful message."""
+    devices = [
+        Device(name="alsa_input.usb-Foo", description="USB Mic", kind="input"),
+    ]
+
+    def raise_eof(_):
+        raise EOFError()
+
+    monkeypatch.setattr("sys.stdin.isatty", lambda: False)
+    monkeypatch.setattr("builtins.input", raise_eof)
+
+    with pytest.raises(RuntimeError, match="No TTY and EOF reached"):
+        select_sources(devices, preselected=[])
+
+
+def test_select_sources_preserves_order(monkeypatch):
+    """Test that result preserves device list order."""
+    devices = [
+        Device(name="device-a", description="A", kind="input"),
+        Device(name="device-b", description="B", kind="input"),
+        Device(name="device-c", description="C", kind="input"),
+    ]
+
+    # Select indices 3, 1, 2 but result should be in device order: 1, 2, 3
+    monkeypatch.setattr("sys.stdin.isatty", lambda: False)
+    monkeypatch.setattr("builtins.input", lambda _: "3 1 2")
+
+    result = select_sources(devices, preselected=[])
+
+    assert result == ["device-a", "device-b", "device-c"]
+
+
+def test_build_choices_preselects_via_checked():
+    """Regression: questionary preselects via Choice.checked, NOT a default=[...] list.
+
+    Passing a list as `default` to questionary.checkbox raises
+    "Invalid `default` value passed ...", which broke `stt-daemon setup`.
+    """
+    pytest.importorskip("questionary")
+    import questionary
+
+    from stt_daemon.picker import _build_choices
+
+    devices = [
+        Device("alsa_input.a", "USB Mic", "input"),
+        Device("alsa_output.b.monitor", "HDMI", "output"),
+    ]
+    choices = _build_choices(devices, {"alsa_output.b.monitor"})
+
+    # Constructing the prompt must not raise.
+    questionary.checkbox("Select audio sources to capture:", choices=choices)
+
+    assert {c.value: c.checked for c in choices} == {
+        "alsa_input.a": False,
+        "alsa_output.b.monitor": True,
+    }
+
+
+def test_select_sources_tui_path(monkeypatch):
+    """TUI path returns the selection in device-list order."""
+    pytest.importorskip("questionary")
+    import questionary
+
+    from stt_daemon import picker
+
+    devices = [
+        Device("b", "B", "input"),
+        Device("a", "A", "input"),
+    ]
+
+    class FakePrompt:
+        def ask(self):
+            return ["a", "b"]
+
+    monkeypatch.setattr("sys.stdin.isatty", lambda: True)
+    monkeypatch.setattr(questionary, "checkbox", lambda *a, **k: FakePrompt())
+
+    assert picker.select_sources(devices, preselected=[]) == ["b", "a"]
+
+
+def test_select_sources_tui_abort_keeps_preselected(monkeypatch):
+    """Aborting the TUI (ask() returns None) preserves the previous selection."""
+    pytest.importorskip("questionary")
+    import questionary
+
+    from stt_daemon import picker
+
+    devices = [Device("a", "A", "input")]
+
+    class AbortPrompt:
+        def ask(self):
+            return None
+
+    monkeypatch.setattr("sys.stdin.isatty", lambda: True)
+    monkeypatch.setattr(questionary, "checkbox", lambda *a, **k: AbortPrompt())
+
+    assert picker.select_sources(devices, preselected=["a"]) == ["a"]
+

+ 282 - 0
tests/test_provisioner.py

@@ -0,0 +1,282 @@
+"""Tests for audio sink provisioner orchestration."""
+
+
+import pytest
+
+from stt_daemon.config import SinkConfig
+from stt_daemon.provisioner import Provisioner
+from stt_daemon.pulse import PulseAudio
+from stt_daemon.state import AudioState, Loopback, StateStore
+
+
+def test_provisioner_setup_creates_modules_in_order(tmp_path):
+    """Test that setup creates modules in the correct order."""
+    state_file = tmp_path / "audio.json"
+    store = StateStore(state_file)
+    cfg = SinkConfig(sink_name="VirtualMicSink", remap_source_name="VirtualMic")
+
+    call_sequence = []
+
+    def fake_runner(args):
+        if "load-module" in args:
+            module_id = len(call_sequence) + 10
+            call_sequence.append((args[1], module_id))
+            return f"{module_id}\n"
+        elif "list" in args and "sinks" in args:
+            return '{"sinks": []}'
+        elif "list" in args and "sources" in args:
+            return '{"sources": []}'
+        else:
+            return ""
+
+    pa = PulseAudio(runner=fake_runner)
+    provisioner = Provisioner(pa, cfg, store)
+
+    provisioner.setup(["alsa_input.usb-Foo", "alsa_output.pci-0000.monitor"])
+
+    # Verify creation order: null-sink, remap, then loopbacks
+    assert call_sequence[0][0] == "module-null-sink"
+    assert call_sequence[1][0] == "module-remap-source"
+    assert call_sequence[2][0] == "module-loopback"
+    assert call_sequence[3][0] == "module-loopback"
+
+    # Verify state is saved
+    loaded_state = store.load()
+    assert loaded_state is not None
+    assert loaded_state.sink_name == "VirtualMicSink"
+    assert len(loaded_state.loopbacks) == 2
+
+
+def test_provisioner_teardown_unloads_in_reverse_order(tmp_path):
+    """Test that teardown unloads modules in reverse order."""
+    state_file = tmp_path / "audio.json"
+    store = StateStore(state_file)
+    cfg = SinkConfig(sink_name="VirtualMicSink", remap_source_name="VirtualMic")
+
+    # Pre-save a state
+    state = AudioState(
+        sink_name="VirtualMicSink",
+        remap_name="VirtualMic",
+        null_sink_module=10,
+        remap_module=11,
+        loopbacks=[
+            Loopback(module_id=12, source="alsa_input.usb-Foo"),
+            Loopback(module_id=13, source="alsa_output.pci-0000.monitor"),
+        ],
+    )
+    store.save(state)
+
+    unload_sequence = []
+
+    def fake_runner(args):
+        if "unload-module" in args:
+            module_id = int(args[1])
+            unload_sequence.append(module_id)
+            return ""
+        elif "list" in args:
+            return '{"sources": [], "sinks": []}'
+        else:
+            return ""
+
+    pa = PulseAudio(runner=fake_runner)
+    provisioner = Provisioner(pa, cfg, store)
+
+    provisioner.teardown()
+
+    # Verify unload order: loopbacks in reverse, then remap, then null-sink
+    assert unload_sequence == [13, 12, 11, 10]
+
+    # Verify state is cleared
+    assert store.load() is None
+
+
+def test_provisioner_ensure_returns_none_if_no_state(tmp_path):
+    """Test that ensure returns None if no saved state exists."""
+    state_file = tmp_path / "audio.json"
+    store = StateStore(state_file)
+    cfg = SinkConfig(sink_name="VirtualMicSink", remap_source_name="VirtualMic")
+
+    def fake_runner(args):
+        if "list" in args and "sinks" in args:
+            return '{"sinks": []}'
+        else:
+            return '{"sources": []}'
+
+    pa = PulseAudio(runner=fake_runner)
+    provisioner = Provisioner(pa, cfg, store)
+
+    result = provisioner.ensure()
+
+    assert result is None
+
+
+def test_provisioner_ensure_returns_state_if_sink_exists(tmp_path):
+    """Test that ensure returns existing state if sink is present."""
+    state_file = tmp_path / "audio.json"
+    store = StateStore(state_file)
+    cfg = SinkConfig(sink_name="VirtualMicSink", remap_source_name="VirtualMic")
+
+    # Pre-save a state
+    state = AudioState(
+        sink_name="VirtualMicSink",
+        remap_name="VirtualMic",
+        null_sink_module=10,
+        remap_module=11,
+        loopbacks=[],
+    )
+    store.save(state)
+
+    def fake_runner(args):
+        if "list" in args and "sinks" in args:
+            return (
+                '{"sinks": [{"name": "VirtualMicSink", '
+                '"monitor_source": "VirtualMicSink.monitor"}]}'
+            )
+        else:
+            return '{"sources": []}'
+
+    pa = PulseAudio(runner=fake_runner)
+    provisioner = Provisioner(pa, cfg, store)
+
+    result = provisioner.ensure()
+
+    assert result is not None
+    assert result.sink_name == "VirtualMicSink"
+
+
+def test_provisioner_ensure_reprovisions_if_sink_missing(tmp_path):
+    """Test that ensure re-provisions if saved state exists but sink is gone."""
+    state_file = tmp_path / "audio.json"
+    store = StateStore(state_file)
+    cfg = SinkConfig(sink_name="VirtualMicSink", remap_source_name="VirtualMic")
+
+    # Pre-save a state
+    original_state = AudioState(
+        sink_name="VirtualMicSink",
+        remap_name="VirtualMic",
+        null_sink_module=10,
+        remap_module=11,
+        loopbacks=[Loopback(module_id=12, source="alsa_input.usb-Foo")],
+        selected_sources=["alsa_input.usb-Foo"],
+    )
+    store.save(original_state)
+
+    module_counter = [20]
+
+    def fake_runner(args):
+        if "list" in args and "sinks" in args:
+            # Sink doesn't exist
+            return '{"sinks": []}'
+        elif "list" in args and "sources" in args:
+            return '{"sources": []}'
+        elif "load-module" in args:
+            module_id = module_counter[0]
+            module_counter[0] += 1
+            return f"{module_id}\n"
+        else:
+            return ""
+
+    pa = PulseAudio(runner=fake_runner)
+    provisioner = Provisioner(pa, cfg, store)
+
+    result = provisioner.ensure()
+
+    assert result is not None
+    assert result.sink_name == "VirtualMicSink"
+    assert result.selected_sources == ["alsa_input.usb-Foo"]
+    # Should have created new modules
+    assert result.null_sink_module == 20
+
+
+def test_provisioner_setup_raises_on_untracked_sink(tmp_path):
+    """Test that setup raises if sink already exists but is not tracked."""
+    state_file = tmp_path / "audio.json"
+    store = StateStore(state_file)
+    cfg = SinkConfig(sink_name="VirtualMicSink", remap_source_name="VirtualMic")
+
+    def fake_runner(args):
+        if "list" in args and "sinks" in args:
+            # VirtualMicSink already exists (from elsewhere)
+            return (
+                '{"sinks": [{"name": "VirtualMicSink", '
+                '"monitor_source": "VirtualMicSink.monitor"}]}'
+            )
+        else:
+            return '{"sources": []}'
+
+    pa = PulseAudio(runner=fake_runner)
+    provisioner = Provisioner(pa, cfg, store)
+
+    with pytest.raises(RuntimeError, match="already exists but is not tracked"):
+        provisioner.setup(["alsa_input.usb-Foo"])
+
+
+def test_provisioner_status_reports_live_modules(tmp_path):
+    """Test that status reports which tracked modules are currently loaded."""
+    state_file = tmp_path / "audio.json"
+    store = StateStore(state_file)
+    cfg = SinkConfig(sink_name="VirtualMicSink", remap_source_name="VirtualMic")
+
+    state = AudioState(
+        sink_name="VirtualMicSink",
+        remap_name="VirtualMic",
+        null_sink_module=10,
+        remap_module=11,
+        loopbacks=[Loopback(module_id=12, source="alsa_input.usb-Foo")],
+    )
+    store.save(state)
+
+    def fake_runner(args):
+        if "list" in args and "sinks" in args:
+            return '{"sinks": [{"name": "VirtualMicSink"}]}'
+        elif "list" in args and "short" in args and "modules" in args:
+            # Only 2 of 3 tracked modules are loaded
+            return "10\tmodule-null-sink\n11\tmodule-remap-source\n"
+        else:
+            return '{"sources": []}'
+
+    pa = PulseAudio(runner=fake_runner)
+    provisioner = Provisioner(pa, cfg, store)
+
+    status = provisioner.status()
+
+    assert status["state"] is not None
+    assert status["sink_present"] is True
+    assert len(status["live_modules"]) == 2
+
+
+def test_provisioner_teardown_tolerates_missing_modules(tmp_path):
+    """Test that teardown tolerates modules that are already gone."""
+    state_file = tmp_path / "audio.json"
+    store = StateStore(state_file)
+    cfg = SinkConfig(sink_name="VirtualMicSink", remap_source_name="VirtualMic")
+
+    state = AudioState(
+        sink_name="VirtualMicSink",
+        remap_name="VirtualMic",
+        null_sink_module=10,
+        remap_module=11,
+    )
+    store.save(state)
+
+    unload_attempts = []
+
+    def fake_runner(args):
+        if "unload-module" in args:
+            module_id = int(args[1])
+            unload_attempts.append(module_id)
+            # Simulate module 11 already being gone
+            if module_id == 11:
+                raise RuntimeError("Module 11 not found")
+            return ""
+        else:
+            return '{"sources": [], "sinks": []}'
+
+    pa = PulseAudio(runner=fake_runner)
+    provisioner = Provisioner(pa, cfg, store)
+
+    # Should not raise
+    provisioner.teardown()
+
+    # State should still be cleared
+    assert store.load() is None

+ 185 - 0
tests/test_pulse.py

@@ -0,0 +1,185 @@
+"""Tests for PulseAudio interface with injectable runner.
+
+Fixtures mirror the ACTUAL shape of `pactl -f json list sources|sinks`:
+a top-level JSON array, where monitor sources carry a non-empty
+``monitor_source`` (the sink they mirror) and real inputs carry ``""``.
+"""
+
+import json
+
+from stt_daemon.pulse import Device, PulseAudio
+
+REAL_SOURCES = [
+    {
+        "index": 12,
+        "name": "alsa_output.pci-0000_05_00.1.hdmi-stereo.monitor",
+        "description": "Monitor of Radeon HDMI",
+        "monitor_source": "alsa_output.pci-0000_05_00.1.hdmi-stereo",
+    },
+    {
+        "index": 15,
+        "name": "alsa_input.usb-C-Media.mono-fallback",
+        "description": "USB Audio Mono",
+        "monitor_source": "",
+    },
+]
+
+REAL_SINKS = [
+    {
+        "index": 12,
+        "name": "alsa_output.pci-0000_05_00.1.hdmi-stereo",
+        "description": "Radeon HDMI",
+        "monitor_source": "alsa_output.pci-0000_05_00.1.hdmi-stereo.monitor",
+    },
+    {
+        "index": 13,
+        "name": "alsa_output.usb-analog-stereo",
+        "description": "USB Analog",
+        "monitor_source": "alsa_output.usb-analog-stereo.monitor",
+    },
+]
+
+
+def _make_pa(sources=None, sinks=None):
+    def fake_runner(args):
+        if "list" in args and "sources" in args:
+            return json.dumps(sources if sources is not None else [])
+        if "list" in args and "sinks" in args:
+            return json.dumps(sinks if sinks is not None else [])
+        raise RuntimeError(f"Unexpected args: {args}")
+
+    return PulseAudio(runner=fake_runner)
+
+
+def test_list_devices_excludes_monitors_and_uses_sink_monitor_source():
+    """A .monitor source must NOT be an input; each sink contributes its monitor as output."""
+    pa = _make_pa(REAL_SOURCES, REAL_SINKS)
+    devices = pa.list_devices()
+
+    inputs = [d for d in devices if d.kind == "input"]
+    outputs = [d for d in devices if d.kind == "output"]
+
+    assert [d.name for d in inputs] == ["alsa_input.usb-C-Media.mono-fallback"]
+    assert [d.name for d in outputs] == [
+        "alsa_output.pci-0000_05_00.1.hdmi-stereo.monitor",
+        "alsa_output.usb-analog-stereo.monitor",
+    ]
+    assert outputs[0].description == "Radeon HDMI"
+
+
+def test_list_devices_exclude():
+    """Excluded names (our own virtual devices) are omitted."""
+    pa = _make_pa(REAL_SOURCES, REAL_SINKS)
+    devices = pa.list_devices(
+        exclude={
+            "alsa_input.usb-C-Media.mono-fallback",
+            "alsa_output.usb-analog-stereo.monitor",
+        }
+    )
+    assert [d.name for d in devices] == [
+        "alsa_output.pci-0000_05_00.1.hdmi-stereo.monitor"
+    ]
+
+
+def test_list_sources_tolerates_dict_wrapped_output():
+    """Parser must not crash on the legacy object-wrapped form."""
+    def runner(args):
+        return json.dumps(
+            {"sources": [{"name": "in1", "description": "In 1", "monitor_source": ""}]}
+        )
+
+    pa = PulseAudio(runner=runner)
+    assert [s["name"] for s in pa.list_sources()] == ["in1"]
+
+
+def test_sink_exists():
+    """Test sink existence check."""
+    pa = _make_pa(REAL_SOURCES, REAL_SINKS)
+
+    assert pa.sink_exists("alsa_output.usb-analog-stereo")
+    assert not pa.sink_exists("VirtualMicSink")
+
+
+def test_source_exists():
+    """Test source existence check."""
+    pa = _make_pa(REAL_SOURCES, REAL_SINKS)
+
+    assert pa.source_exists("alsa_input.usb-C-Media.mono-fallback")
+    assert pa.source_exists("alsa_output.pci-0000_05_00.1.hdmi-stereo.monitor")
+    assert not pa.source_exists("nonexistent")
+
+
+def test_load_module_parses_id():
+    """Test that load_module parses the numeric ID from pactl output."""
+    def fake_runner(args):
+        if "load-module" in args:
+            return "42\n"
+        raise RuntimeError(f"Unexpected args: {args}")
+
+    pa = PulseAudio(runner=fake_runner)
+    assert pa.load_module("module-null-sink", "sink_name=VirtualMicSink") == 42
+
+
+def test_unload_module_success():
+    """Test unload_module returns True on success."""
+    calls = []
+
+    def fake_runner(args):
+        if "unload-module" in args:
+            calls.append(args)
+            return ""
+        raise RuntimeError(f"Unexpected args: {args}")
+
+    pa = PulseAudio(runner=fake_runner)
+    assert pa.unload_module(42) is True
+    assert len(calls) == 1
+
+
+def test_unload_module_already_gone_real_message():
+    """pactl reports a vanished module as 'Failure: No such entity'."""
+    def fake_runner(args):
+        if "unload-module" in args:
+            raise RuntimeError("pactl failed: Failure: No such entity\n")
+        raise RuntimeError(f"Unexpected args: {args}")
+
+    pa = PulseAudio(runner=fake_runner)
+    assert pa.unload_module(42) is False
+
+
+def test_short_modules_parsing():
+    """Test that short_modules parses pactl list short modules output."""
+    output = "0\tmodule-cli-protocol-tcp\n12\tmodule-null-sink\n13\tmodule-loopback\n"
+
+    def fake_runner(args):
+        if "short" in args and "modules" in args:
+            return output
+        raise RuntimeError(f"Unexpected args: {args}")
+
+    pa = PulseAudio(runner=fake_runner)
+    modules = pa.short_modules()
+
+    assert modules == [
+        (0, "module-cli-protocol-tcp"),
+        (12, "module-null-sink"),
+        (13, "module-loopback"),
+    ]
+
+
+def test_default_sink_name():
+    """Test default_sink_name returns the sink name."""
+    def fake_runner(args):
+        if "get-default-sink" in args:
+            return "alsa_output.usb-analog-stereo\n"
+        raise RuntimeError(f"Unexpected args: {args}")
+
+    pa = PulseAudio(runner=fake_runner)
+    assert pa.default_sink_name() == "alsa_output.usb-analog-stereo"
+
+
+def test_device_dataclass():
+    """Test Device dataclass."""
+    device = Device(name="alsa_input.usb-Foo", description="USB Input", kind="input")
+
+    assert device.name == "alsa_input.usb-Foo"
+    assert device.description == "USB Input"
+    assert device.kind == "input"

+ 410 - 0
tests/test_segmenter.py

@@ -0,0 +1,410 @@
+"""Tests for the VAD segmenter with pre-roll buffer."""
+
+import numpy as np
+import pytest
+import webrtcvad
+
+from stt_daemon.segmenter import FrameSegmenter, make_vad
+
+
+class FakeVad:
+    """A VAD for testing: returns scripted speech/non-speech decisions."""
+
+    def __init__(self, decisions: list[bool]):
+        """Initialize with a sequence of boolean decisions.
+
+        Args:
+            decisions: List of True (speech) / False (silence) per frame.
+        """
+        self.decisions = decisions
+        self.frame_index = 0
+
+    def is_speech(self, frame: bytes, sample_rate: int) -> bool:
+        """Return the next scripted decision."""
+        if self.frame_index >= len(self.decisions):
+            # If we've exhausted decisions, default to False
+            return False
+        result = self.decisions[self.frame_index]
+        self.frame_index += 1
+        return result
+
+
+def make_frame(samples: np.ndarray) -> bytes:
+    """Convert samples to int16 PCM bytes."""
+    assert samples.dtype == np.int16
+    return samples.tobytes()
+
+
+def make_silent_frame(frame_samples: int) -> bytes:
+    """Create a silent frame (zeros)."""
+    return make_frame(np.zeros(frame_samples, dtype=np.int16))
+
+
+def make_speech_frame(frame_samples: int, value: int = 1000) -> bytes:
+    """Create a frame with some amplitude."""
+    return make_frame(np.full(frame_samples, value, dtype=np.int16))
+
+
+def test_make_vad_invalid_aggressiveness():
+    """Test that make_vad validates aggressiveness."""
+    with pytest.raises(ValueError):
+        make_vad(4)
+    with pytest.raises(ValueError):
+        make_vad(-1)
+
+
+def test_make_vad_valid():
+    """Test that make_vad accepts 0-3."""
+    for aggr in (0, 1, 2, 3):
+        vad = make_vad(aggr)
+        assert isinstance(vad, webrtcvad.Vad)
+
+
+def test_preroll_preserved():
+    """Test that pre-roll buffer content is included in the emitted utterance.
+
+    Pre-roll frames are kept in a ring buffer before speech is confirmed.
+    When speech is confirmed, those pre-roll frames should be at the start
+    of the emitted utterance, ensuring the first phoneme is not cut off.
+    """
+    sample_rate = 16000
+    frame_ms = 30
+    frame_samples = round(sample_rate * frame_ms / 1000)
+
+    # Pre-roll frames: 2 silent frames (60ms silence before speech)
+    # Then: 3 speech frames to confirm speech (90ms to trigger confirmation)
+    # Then: 1 more speech frame + 2 silence frames to finalize
+
+    fake_vad = FakeVad(
+        [
+            False,  # Frame 0: pre-roll silence
+            False,  # Frame 1: pre-roll silence
+            True,   # Frame 2: speech (count=1)
+            True,   # Frame 3: speech (count=2)
+            True,   # Frame 4: speech (count=3, confirmed)
+            True,   # Frame 5: speech (during speaking)
+            False,  # Frame 6: silence (count=1)
+            False,  # Frame 7: silence (count=2, finalize)
+        ]
+    )
+
+    segmenter = FrameSegmenter(
+        sample_rate=sample_rate,
+        frame_ms=frame_ms,
+        vad=fake_vad,
+        pre_roll_ms=150,   # 5 frames (enough for 2 silence + 3 speech to not lose frame 0)
+        speech_start_ms=90,  # 3 frames needed
+        silence_ms=60,    # 2 frames to finalize
+        min_utterance_ms=30,
+        max_utterance_s=30,
+    )
+
+    pre_roll_frame_0 = make_silent_frame(frame_samples)
+    pre_roll_frame_1 = make_silent_frame(frame_samples)
+    speech_frame_2 = make_speech_frame(frame_samples)
+    speech_frame_3 = make_speech_frame(frame_samples)
+    speech_frame_4 = make_speech_frame(frame_samples)
+    speech_frame_5 = make_speech_frame(frame_samples)
+    silence_frame_6 = make_silent_frame(frame_samples)
+    silence_frame_7 = make_silent_frame(frame_samples)
+
+    # Push pre-roll frames (should be buffered, not emitted)
+    assert segmenter.push(pre_roll_frame_0) is None
+    assert segmenter.push(pre_roll_frame_1) is None
+
+    # Push speech frames until confirmation (frame 4 confirms)
+    assert segmenter.push(speech_frame_2) is None
+    assert segmenter.push(speech_frame_3) is None
+    utt = segmenter.push(speech_frame_4)
+    assert utt is None  # _begin() returns None, not the utterance yet
+
+    # Continue with more frames
+    assert segmenter.push(speech_frame_5) is None
+    assert segmenter.push(silence_frame_6) is None
+
+    # Final silence triggers finalization
+    utt = segmenter.push(silence_frame_7)
+    assert utt is not None, "Utterance should be emitted after silence"
+
+    # Verify that the utterance contains the pre-roll frames and speech frames
+    # Expected: pre_roll_0, pre_roll_1, speech_2-5, silence_6-7
+    # (silence frames kept as tail when finalizing with keep_tail=True)
+    expected_sample_count = frame_samples * 8
+    actual_sample_count = len(utt.audio)
+
+    assert actual_sample_count == expected_sample_count, (
+        f"Expected {expected_sample_count} samples (8 frames), "
+        f"got {actual_sample_count}"
+    )
+
+    # Verify the audio starts with zeros (the pre-roll silence frames 0-1)
+    pre_roll_audio = utt.audio[:2 * frame_samples]
+    assert np.allclose(pre_roll_audio, 0, atol=0.01), (
+        "Pre-roll should be silent at the start (first 2 frames)"
+    )
+
+    # Verify speech frames come next (non-zero)
+    speech_audio = utt.audio[2 * frame_samples : 6 * frame_samples]
+    assert not np.allclose(speech_audio, 0, atol=0.01), (
+        "Speech frames should be non-silent"
+    )
+
+
+def test_silence_finalization():
+    """Test that silence_frames consecutive frames trigger finalization."""
+    sample_rate = 16000
+    frame_ms = 30
+    frame_samples = round(sample_rate * frame_ms / 1000)
+
+    fake_vad = FakeVad(
+        [
+            True,   # Start of speech
+            True,
+            True,
+            True,   # 4 speech frames
+            False,  # Silence count=1
+            False,  # Silence count=2
+            False,  # Silence count=3 (finalizes if silence_ms=90)
+        ]
+    )
+
+    segmenter = FrameSegmenter(
+        sample_rate=sample_rate,
+        frame_ms=frame_ms,
+        vad=fake_vad,
+        pre_roll_ms=60,      # 2 frames
+        speech_start_ms=30,  # 1 frame to confirm
+        silence_ms=60,       # 2 frames to finalize
+        min_utterance_ms=30,
+        max_utterance_s=30,
+    )
+
+    # Push 1 speech frame to trigger confirmation
+    utt = segmenter.push(make_speech_frame(frame_samples))
+    assert utt is None
+
+    # Push 3 more speech frames
+    for _ in range(3):
+        assert segmenter.push(make_speech_frame(frame_samples)) is None
+
+    # Push 1 silence frame (count=1)
+    assert segmenter.push(make_silent_frame(frame_samples)) is None
+
+    # Push 2nd silence frame (count=2, finalizes)
+    utt = segmenter.push(make_silent_frame(frame_samples))
+    assert utt is not None
+    # At finalization with 2 trailing silence frames, we keep those as tail.
+    # Segment progression:
+    # - After frame 0 (speech confirm): segment = [frame_0]
+    # - After frames 1-3: segment = [0, 1, 2, 3]
+    # - After frame 4 (1st silence): segment = [0, 1, 2, 3, 4], silence_run=1
+    # - After frame 5 (2nd silence): segment = [0, 1, 2, 3, 4, 5], silence_run=2
+    # Finalize: emit 6 frames, keep tail [4, 5] for next pre-roll
+    assert len(utt.audio) == frame_samples * 6
+
+
+def test_min_utterance_discarded():
+    """Test that utterances shorter than min_utterance_ms are discarded."""
+    sample_rate = 16000
+    frame_ms = 30
+    frame_samples = round(sample_rate * frame_ms / 1000)
+
+    fake_vad = FakeVad(
+        [
+            True,   # 1 speech frame (30ms, less than min 300ms)
+            False,  # Silence to finalize
+            False,
+        ]
+    )
+
+    segmenter = FrameSegmenter(
+        sample_rate=sample_rate,
+        frame_ms=frame_ms,
+        vad=fake_vad,
+        pre_roll_ms=0,
+        speech_start_ms=30,  # 1 frame
+        silence_ms=60,       # 2 frames
+        min_utterance_ms=300,  # Only keep if >= 300ms (10 frames at 30ms)
+        max_utterance_s=30,
+    )
+
+    # Confirm speech with 1 frame
+    segmenter.push(make_speech_frame(frame_samples))
+
+    # Finalize with 2 silence frames
+    segmenter.push(make_silent_frame(frame_samples))
+    utt = segmenter.push(make_silent_frame(frame_samples))
+
+    assert utt is None, "Short utterance should be discarded"
+
+
+def test_max_utterance_forces_finalization():
+    """Test that max_utterance_s force-finalizes long continuous speech."""
+    sample_rate = 16000
+    frame_ms = 30
+    frame_samples = round(sample_rate * frame_ms / 1000)
+    max_utterance_s = 0.3  # 300ms, ~10 frames
+
+    # Create a long sequence of speech with no silence
+    num_frames = 15
+    fake_vad = FakeVad([True] * num_frames)
+
+    segmenter = FrameSegmenter(
+        sample_rate=sample_rate,
+        frame_ms=frame_ms,
+        vad=fake_vad,
+        pre_roll_ms=0,
+        speech_start_ms=30,  # 1 frame to confirm
+        silence_ms=60,
+        min_utterance_ms=30,
+        max_utterance_s=max_utterance_s,
+    )
+
+    # Confirm speech
+    segmenter.push(make_speech_frame(frame_samples))
+
+    # Push more frames; should finalize when max is hit
+    utt = None
+    for i in range(1, num_frames):
+        utt = segmenter.push(make_speech_frame(frame_samples))
+        if utt is not None:
+            break
+
+    assert utt is not None, "Max duration should trigger finalization"
+    # Should have finalized at ~max_utterance_s (a bit flexible due to frame alignment)
+    assert len(utt.audio) >= frame_samples * 10
+
+
+def test_flush_finalizes_inprogress():
+    """Test that flush() finalizes an in-progress utterance."""
+    sample_rate = 16000
+    frame_ms = 30
+    frame_samples = round(sample_rate * frame_ms / 1000)
+
+    fake_vad = FakeVad([False, False, True, True])
+
+    segmenter = FrameSegmenter(
+        sample_rate=sample_rate,
+        frame_ms=frame_ms,
+        vad=fake_vad,
+        pre_roll_ms=60,       # 2 frames
+        speech_start_ms=60,   # 2 frames
+        silence_ms=300,
+        min_utterance_ms=30,
+        max_utterance_s=30,
+    )
+
+    # Pre-roll frames (silence)
+    segmenter.push(make_silent_frame(frame_samples))
+    segmenter.push(make_silent_frame(frame_samples))
+
+    # Confirm speech (need 2 consecutive)
+    segmenter.push(make_speech_frame(frame_samples))
+    segmenter.push(make_speech_frame(frame_samples))
+
+    # Now call flush without sending silence
+    utt = segmenter.flush()
+    assert utt is not None
+    # Ring at confirmation has the 2 speech frames, so segment = [speech_0, speech_1]
+    assert len(utt.audio) == frame_samples * 2  # The 2 speech frames
+
+
+def test_no_utterance_if_never_speaks():
+    """Test that a stream of only silence emits no utterance."""
+    sample_rate = 16000
+    frame_ms = 30
+    frame_samples = round(sample_rate * frame_ms / 1000)
+
+    fake_vad = FakeVad([False, False, False, False])
+
+    segmenter = FrameSegmenter(
+        sample_rate=sample_rate,
+        frame_ms=frame_ms,
+        vad=fake_vad,
+        pre_roll_ms=60,
+        speech_start_ms=60,
+        silence_ms=60,
+        min_utterance_ms=30,
+        max_utterance_s=30,
+    )
+
+    # Push only silence
+    for _ in range(4):
+        utt = segmenter.push(make_silent_frame(frame_samples))
+        assert utt is None
+
+    # Flush should also return None
+    utt = segmenter.flush()
+    assert utt is None
+
+
+def test_blip_discarded_with_realistic_pre_roll():
+    """Regression: min_utterance_ms must count real speech, not pre-roll padding.
+
+    With the default 400 ms pre-roll the segment is always at least 13 frames
+    long, so a length-based check would never discard a blip. Only the actual
+    number of speech frames should decide.
+    """
+    sample_rate = 16000
+    frame_ms = 30
+    frame_samples = round(sample_rate * frame_ms / 1000)
+
+    fake_vad = FakeVad([False] * 15 + [True] * 3)
+
+    segmenter = FrameSegmenter(
+        sample_rate=sample_rate,
+        frame_ms=frame_ms,
+        vad=fake_vad,
+        pre_roll_ms=400,
+        speech_start_ms=90,
+        silence_ms=700,
+        min_utterance_ms=300,
+        max_utterance_s=30.0,
+    )
+
+    for _ in range(15):
+        assert segmenter.push(make_silent_frame(frame_samples)) is None
+    for _ in range(3):
+        assert segmenter.push(make_speech_frame(frame_samples)) is None
+
+    utt = None
+    for _ in range(40):
+        utt = segmenter.push(make_silent_frame(frame_samples))
+        if utt is not None or not segmenter.speaking:
+            break
+
+    assert utt is None, "90ms blip must be dropped even though pre-roll inflates the segment"
+
+
+def test_real_speech_kept_with_realistic_pre_roll():
+    """Complement to the blip test: a real utterance with enough speech is kept."""
+    sample_rate = 16000
+    frame_ms = 30
+    frame_samples = round(sample_rate * frame_ms / 1000)
+
+    fake_vad = FakeVad([False] * 15 + [True] * 12)
+
+    segmenter = FrameSegmenter(
+        sample_rate=sample_rate,
+        frame_ms=frame_ms,
+        vad=fake_vad,
+        pre_roll_ms=400,
+        speech_start_ms=90,
+        silence_ms=700,
+        min_utterance_ms=300,
+        max_utterance_s=30.0,
+    )
+
+    for _ in range(15):
+        assert segmenter.push(make_silent_frame(frame_samples)) is None
+    for _ in range(12):
+        segmenter.push(make_speech_frame(frame_samples))
+
+    utt = None
+    for _ in range(40):
+        utt = segmenter.push(make_silent_frame(frame_samples))
+        if utt is not None:
+            break
+
+    assert utt is not None
+    assert len(utt.audio) >= frame_samples * 12

+ 140 - 0
tests/test_state.py

@@ -0,0 +1,140 @@
+"""Tests for audio state persistence."""
+
+import json
+from pathlib import Path
+
+
+from stt_daemon.state import AudioState, Loopback, StateStore
+
+
+def test_state_store_save_and_load(tmp_path):
+    """Test round-trip save and load of state."""
+    state_file = tmp_path / "audio.json"
+    store = StateStore(state_file)
+
+    original_state = AudioState(
+        sink_name="VirtualMicSink",
+        remap_name="VirtualMic",
+        null_sink_module=10,
+        remap_module=11,
+        loopbacks=[
+            Loopback(module_id=12, source="alsa_input.usb-Foo"),
+            Loopback(module_id=13, source="alsa_output.pci-0000.monitor"),
+        ],
+        selected_sources=["alsa_input.usb-Foo", "alsa_output.pci-0000.monitor"],
+    )
+
+    store.save(original_state)
+    loaded_state = store.load()
+
+    assert loaded_state is not None
+    assert loaded_state.sink_name == original_state.sink_name
+    assert loaded_state.remap_name == original_state.remap_name
+    assert loaded_state.null_sink_module == original_state.null_sink_module
+    assert loaded_state.remap_module == original_state.remap_module
+    assert len(loaded_state.loopbacks) == 2
+    assert loaded_state.loopbacks[0].module_id == 12
+    assert loaded_state.loopbacks[0].source == "alsa_input.usb-Foo"
+    assert loaded_state.selected_sources == original_state.selected_sources
+
+
+def test_state_store_load_missing():
+    """Test that load returns None for missing file."""
+    store = StateStore(Path("/nonexistent/path/audio.json"))
+    loaded_state = store.load()
+
+    assert loaded_state is None
+
+
+def test_state_store_load_corrupt_json(tmp_path):
+    """Test that load returns None for corrupt JSON."""
+    state_file = tmp_path / "audio.json"
+    state_file.write_text("{ invalid json }")
+
+    store = StateStore(state_file)
+    loaded_state = store.load()
+
+    assert loaded_state is None
+
+
+def test_state_store_clear(tmp_path):
+    """Test that clear removes the state file."""
+    state_file = tmp_path / "audio.json"
+    store = StateStore(state_file)
+
+    state = AudioState(
+        sink_name="VirtualMicSink",
+        remap_name="VirtualMic",
+        null_sink_module=10,
+        remap_module=11,
+    )
+    store.save(state)
+
+    assert state_file.exists()
+
+    store.clear()
+
+    assert not state_file.exists()
+
+
+def test_state_store_default_path_env_var(tmp_path, monkeypatch):
+    """Test that StateStore uses STT_DAEMON_STATE env var."""
+    state_file = tmp_path / "custom_audio.json"
+    monkeypatch.setenv("STT_DAEMON_STATE", str(state_file))
+
+    store = StateStore()
+
+    assert store.path == state_file
+
+
+def test_state_store_default_path_xdg(tmp_path, monkeypatch):
+    """Test that StateStore uses XDG_STATE_HOME for default path."""
+    monkeypatch.delenv("STT_DAEMON_STATE", raising=False)
+    monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path))
+
+    store = StateStore()
+
+    expected_path = tmp_path / "stt-daemon" / "audio.json"
+    assert store.path == expected_path
+
+
+def test_state_store_creates_parent_dirs(tmp_path):
+    """Test that save creates parent directories."""
+    state_file = tmp_path / "deep" / "nested" / "dirs" / "audio.json"
+    store = StateStore(state_file)
+
+    state = AudioState(
+        sink_name="VirtualMicSink",
+        remap_name="VirtualMic",
+        null_sink_module=10,
+        remap_module=11,
+    )
+
+    store.save(state)
+
+    assert state_file.parent.exists()
+    assert state_file.exists()
+
+
+def test_state_store_load_respects_json_format(tmp_path):
+    """Test that load respects the JSON format with loopbacks."""
+    state_file = tmp_path / "audio.json"
+
+    data = {
+        "sink_name": "VirtualMicSink",
+        "remap_name": "VirtualMic",
+        "null_sink_module": 10,
+        "remap_module": 11,
+        "loopbacks": [
+            {"module_id": 12, "source": "alsa_input.usb-Foo"},
+        ],
+        "selected_sources": ["alsa_input.usb-Foo"],
+    }
+
+    state_file.write_text(json.dumps(data))
+
+    store = StateStore(state_file)
+    loaded_state = store.load()
+
+    assert loaded_state is not None
+    assert loaded_state.selected_sources == ["alsa_input.usb-Foo"]

+ 872 - 0
uv.lock

@@ -0,0 +1,872 @@
+version = 1
+revision = 3
+requires-python = ">=3.11"
+resolution-markers = [
+    "python_full_version >= '3.12'",
+    "python_full_version < '3.12'",
+]
+
+[[package]]
+name = "anyio"
+version = "4.15.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "idna" },
+    { name = "typing-extensions", marker = "python_full_version < '3.15'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
+]
+
+[[package]]
+name = "av"
+version = "18.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/8d/f4/f22114d30d3435e38c6af2b4870f37b864403dca6ae7af747a289ce0a18e/av-18.1.0.tar.gz", hash = "sha256:47bfc286e1bc9de7ab4681fc2b575cd2460a66919d31ffe1bd5aa54fae531a28", size = 4451061, upload-time = "2026-08-12T22:28:18.761Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/05/d4/d7cdc8bff143c17a6d35924375ae28dd692cacde38700a7d419fde54f44a/av-18.1.0-cp311-abi3-macosx_11_0_x86_64.whl", hash = "sha256:ae75d8bb6467895ed1f8572ededf7ffa49eac07f6e483222f5d7d62a41d12f04", size = 22546147, upload-time = "2026-08-12T22:27:11.851Z" },
+    { url = "https://files.pythonhosted.org/packages/3f/c9/37a619297492256b77d5ed906e7d8166c10a26ed251dccf1ae03ab19bff6/av-18.1.0-cp311-abi3-macosx_14_0_arm64.whl", hash = "sha256:b30a4e8d934558e19602b68998a4d9ac9f250fa0dacef216f7e8e40153b13316", size = 18217603, upload-time = "2026-08-12T22:27:14.713Z" },
+    { url = "https://files.pythonhosted.org/packages/d9/84/2464ffb64c08c5ce8b522c8e74594714414e3b0575267652c5c51c0574b9/av-18.1.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6fc837cc51adf80331ac850779cd53b5d4c4460b0ebe9057a02a921c6736f19d", size = 33640142, upload-time = "2026-08-12T22:27:17.835Z" },
+    { url = "https://files.pythonhosted.org/packages/27/3a/204dbfc3e08eb4cdc6e6ff57be02150bc44523ebdb50182d10025792ebd9/av-18.1.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:8a032e8d8ebc73dec079364b9b4a6837638a2d106e8472314e685ffbf163e700", size = 35786210, upload-time = "2026-08-12T22:27:20.984Z" },
+    { url = "https://files.pythonhosted.org/packages/e1/99/b0d04ec553ff9a7e00455458dfa3a39c8a8f627b273056b4e5fe57d590de/av-18.1.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:3c8b1f8b46f99d52e2d8b0ed5d0cdadf172d24794d46e2077b16e44ed08e26ff", size = 39379798, upload-time = "2026-08-12T22:27:24.432Z" },
+    { url = "https://files.pythonhosted.org/packages/56/b1/e00d4feae59160149df6126585e726fdc6300798fd40c5dd324879e81f68/av-18.1.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ab5ac081bc9eaf54109120d4e56284674fecfbe520d9aa1707c7fa911ec5f4d2", size = 34690321, upload-time = "2026-08-12T22:27:27.769Z" },
+    { url = "https://files.pythonhosted.org/packages/dc/94/836fa987e3084d11a21489f11357fb24843ef3aa8faf74ddddfc603d5062/av-18.1.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:191224788d87af06c31784a395bb73f14b72f33d7f4871ace0157de2abdc6276", size = 36859932, upload-time = "2026-08-12T22:27:31.403Z" },
+    { url = "https://files.pythonhosted.org/packages/33/b4/76ba21e46704f632004276b85289a1582e95f5eff760436d6149875a1881/av-18.1.0-cp311-abi3-win_amd64.whl", hash = "sha256:ea1480b7a8d5405cb5f382b344731bf125fd2c1c6fae3964f6c48595628387ff", size = 27595679, upload-time = "2026-08-12T22:27:35.177Z" },
+    { url = "https://files.pythonhosted.org/packages/4f/ad/a3135884c5753b09773176b97201ae602f67ad14206c395ff838d66bf9b0/av-18.1.0-cp311-abi3-win_arm64.whl", hash = "sha256:5509ec12aaa19fd6601de13cfa6f4cdad450da07982118510592875d970454d6", size = 20257584, upload-time = "2026-08-12T22:27:38.472Z" },
+    { url = "https://files.pythonhosted.org/packages/4f/5b/4a756265d7fb164336c8d377bca21c39cfa2c178be23cedee840a69b59c5/av-18.1.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:b36b0bae9e4c62f9487c99481ec15e4e3870fcc868522cd6d18fc2d6bfa04f01", size = 22795654, upload-time = "2026-08-12T22:27:42.016Z" },
+    { url = "https://files.pythonhosted.org/packages/d5/cc/1bc841462114a1adf4f7d87456ab78a6972e23271e71865fcd2bbd0e7360/av-18.1.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:025f84494cb23278498f03b0d8117d3e47a1cbc9c44b97eb31875cf02251e46b", size = 18435735, upload-time = "2026-08-12T22:27:45.787Z" },
+    { url = "https://files.pythonhosted.org/packages/b8/20/005500ed17a2e62a5e4bb94aa3786942560ec2f55ec1895ebf174c87abef/av-18.1.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:08a9ae288299cfcbf739dba4ad0c53b9b71f45184303dd45947920d022fed695", size = 37090807, upload-time = "2026-08-12T22:27:50.14Z" },
+    { url = "https://files.pythonhosted.org/packages/5c/f7/11e7f6d848d3690c31ca4f8578167393e619177f1493ccc93b9400852d4e/av-18.1.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:cf8a17466bef07765dbdecc9e66ed9b25d20b4e14f654fbf35345a58ac45fa0c", size = 38976836, upload-time = "2026-08-12T22:27:54.565Z" },
+    { url = "https://files.pythonhosted.org/packages/c3/63/b271473b24e806062d31191e40c6d65545e9cf59f80f044eba56dcbba0f4/av-18.1.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d49a5c542dfdc00f43c6cdb6cc41dac1781ee206fe180b56aa7433dfa816dfae", size = 40896630, upload-time = "2026-08-12T22:27:59.118Z" },
+    { url = "https://files.pythonhosted.org/packages/6b/9f/2ab7fa292a947ad3466ed8e655eefa3b82f535d7ea598c297b4471a937c4/av-18.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5548b79e2bf1f59b3e9aedc918a72d9dc45b9adaac10ff9470d5dbdda0002e47", size = 37895673, upload-time = "2026-08-12T22:28:03.98Z" },
+    { url = "https://files.pythonhosted.org/packages/e9/d8/04507c57249b399c3e4f23f01d221532f357338b5316fd2858fbd343127d/av-18.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7ea063f6690193ea335a1d592d6e0274350d45e2ed6af83ee107cb90cbfd84f", size = 39992431, upload-time = "2026-08-12T22:28:08.736Z" },
+    { url = "https://files.pythonhosted.org/packages/d6/d6/bc4b95bea9c2353a7e4d62a3fcfad9adcf0f881741c6ce01ee179d539ce3/av-18.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e4d48b9f12cad009cc72fe4f4099107de5e819c95f82767f4fd01a01481c0661", size = 28497798, upload-time = "2026-08-12T22:28:13.003Z" },
+    { url = "https://files.pythonhosted.org/packages/c1/d2/0c277a46f12647c1833f40496e132fb6001e0d19e6144b5ea30896461feb/av-18.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5cd9085028902c9880622bd37a12fd4b33060f06a52311f6f4867ca9f29a2c3b", size = 21421979, upload-time = "2026-08-12T22:28:16.48Z" },
+]
+
+[[package]]
+name = "certifi"
+version = "2026.7.22"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
+]
+
+[[package]]
+name = "cffi"
+version = "2.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "pycparser", marker = "implementation_name != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" },
+    { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" },
+    { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" },
+    { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" },
+    { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" },
+    { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" },
+    { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" },
+    { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" },
+    { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" },
+    { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" },
+    { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" },
+    { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" },
+    { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" },
+    { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" },
+    { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" },
+    { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" },
+    { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" },
+    { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" },
+    { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" },
+    { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" },
+    { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" },
+    { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" },
+    { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" },
+    { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" },
+    { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" },
+    { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" },
+    { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" },
+    { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" },
+    { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" },
+    { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" },
+    { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" },
+    { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" },
+    { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" },
+    { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" },
+    { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" },
+    { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" },
+    { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" },
+    { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" },
+    { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" },
+    { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" },
+    { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" },
+    { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" },
+    { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" },
+    { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" },
+    { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" },
+    { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" },
+    { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" },
+    { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" },
+    { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" },
+    { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" },
+    { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" },
+    { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" },
+    { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" },
+    { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" },
+    { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" },
+    { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" },
+    { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" },
+    { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" },
+    { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" },
+    { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" },
+    { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" },
+    { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" },
+    { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" },
+    { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" },
+    { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" },
+    { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" },
+    { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" },
+    { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" },
+    { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" },
+    { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" },
+    { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" },
+    { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" },
+    { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" },
+    { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" },
+    { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" },
+    { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" },
+    { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" },
+    { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" },
+    { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" },
+    { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" },
+    { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" },
+    { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" },
+    { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" },
+    { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" },
+    { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" },
+    { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" },
+    { 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 = "click"
+version = "8.5.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "ctranslate2"
+version = "4.8.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { 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 = "pyyaml" },
+]
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/43/17/f22fbbb0723891704af803e1e7af5541bb46013adb347f870cbd8c4834d3/ctranslate2-4.8.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cb2a5b4c206b3bd1f7f4b44d07e86e42050af6753053cdc86444f8d0ead9d6d8", size = 1271641, upload-time = "2026-08-31T19:37:07.757Z" },
+    { url = "https://files.pythonhosted.org/packages/b2/af/cfe767012a0809c15155ecae3125ca66a16aeee4669bae146220cc631a76/ctranslate2-4.8.2-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:8221875b09ef982e3579a5165f8c9fcfb3d5de0f710af228eca2f73d69635ace", size = 11929414, upload-time = "2026-08-31T19:37:09.435Z" },
+    { url = "https://files.pythonhosted.org/packages/10/2e/2d08b1219303af7256aeb78b98ee847ffb229c5fe4815e6d3a1b6483846a/ctranslate2-4.8.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3823c9883c2c410a76b2f19feda9da628a3c112cedfd912db815e0055c8235e2", size = 16720002, upload-time = "2026-08-31T19:37:11.957Z" },
+    { url = "https://files.pythonhosted.org/packages/f3/3d/75c029ffb484957f1d2be0f9c3b9b1474955bb16c22df85bcba50c48df11/ctranslate2-4.8.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a24e0a95151b970941867fec94c983df978630f1996242a587c072d455607d28", size = 39378274, upload-time = "2026-08-31T19:37:14.898Z" },
+    { url = "https://files.pythonhosted.org/packages/7e/86/21926da682103a20d4f4833b389131b801c71e6b8d6047be5f0a28fe673a/ctranslate2-4.8.2-cp311-cp311-win_amd64.whl", hash = "sha256:995938fcd24a1174a7abf9765e7fa216b5b91a1d8e8c4c8f383c7a186e8bab2e", size = 19220522, upload-time = "2026-08-31T19:37:17.845Z" },
+    { url = "https://files.pythonhosted.org/packages/12/97/9c63a51a8c8e13e95ec2aec639460fb0f271b0421e1a365b8aad440385c4/ctranslate2-4.8.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fedda669421a57f8164568ee86ea265727016988d310fcc2e24ca30e9ec457cd", size = 1271613, upload-time = "2026-08-31T19:37:19.958Z" },
+    { url = "https://files.pythonhosted.org/packages/ca/27/e419389fb6040a8170bb30c3dcd50c29b70638852a264acc5bf804bf8800/ctranslate2-4.8.2-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:5385f15493e7b6f41377da83d0d5afeb8e0f36188260722f3800c3c7121455af", size = 11931717, upload-time = "2026-08-31T19:37:21.897Z" },
+    { url = "https://files.pythonhosted.org/packages/53/46/bfa42114fd583b0ef30b67113486ab72d3ff466e60a824739257bcc533bd/ctranslate2-4.8.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:781f835da7fdc4adcc38f9d674dbe9b37eaf3c9aee3b2bc17d684342ff59d549", size = 16894628, upload-time = "2026-08-31T19:37:24.328Z" },
+    { url = "https://files.pythonhosted.org/packages/01/23/d72e70cac2b7c5c832a629c380235b53b20fac8c0921856bcf625b08ba44/ctranslate2-4.8.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:554a0b2abf098a11c66ef7930c0a05b3de683a455e8ea3f2e6ab6966aaabc136", size = 39555908, upload-time = "2026-08-31T19:37:27.948Z" },
+    { url = "https://files.pythonhosted.org/packages/4e/23/e3b5322ff7368fcbed181ea4c209149416e7940b5b04971d5ee4084afe1a/ctranslate2-4.8.2-cp312-cp312-win_amd64.whl", hash = "sha256:d94421d565d0de61c032998f737a18942b0f2bef40c0424b1846ec6f67300105", size = 19222069, upload-time = "2026-08-31T19:37:31.531Z" },
+    { url = "https://files.pythonhosted.org/packages/2c/00/3f5d12d94daa22ddf51de0252313476c0f8fca8b1c7776592953764a8b85/ctranslate2-4.8.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a40b190248389e1adb38ff90407937131c6bb9d7f9771f29ca1dca01946ff252", size = 1271572, upload-time = "2026-08-31T19:37:33.476Z" },
+    { url = "https://files.pythonhosted.org/packages/b4/b5/fde838502472462f2c8fd9e8fe17e734854bbe1047452c28d4fbe3eab7b2/ctranslate2-4.8.2-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:2778eaf89340062feb0d2d3dd35bedb14644673ccbce8934782dc8d093b2cab9", size = 11931591, upload-time = "2026-08-31T19:37:35.016Z" },
+    { url = "https://files.pythonhosted.org/packages/a6/c7/cf22407330c90c1f3ef642ece11ff6bfe686509e995b95545951edf29117/ctranslate2-4.8.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:249b35f575adf8dd11e6ba43b212d5cbfc845d1140abc610c2872eef1ffb3481", size = 16895415, upload-time = "2026-08-31T19:37:37.342Z" },
+    { url = "https://files.pythonhosted.org/packages/89/8b/051962470b8e9df1a43f0c9c7a03ce1c8a279db76be4e6a6fcb5171d1d29/ctranslate2-4.8.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:633f19d1ff8d053a8b179ca742b242002ac7882a2ab44e319c6efcde27d586a4", size = 39555517, upload-time = "2026-08-31T19:37:40.87Z" },
+    { url = "https://files.pythonhosted.org/packages/c2/fc/a9e9e0ce1c0a29bc4c17bf56ccb4274293c0bbb2b8aae561727d82fcb0ca/ctranslate2-4.8.2-cp313-cp313-win_amd64.whl", hash = "sha256:399c20a7336b6358f69ce3c615e090eabc08f29735a079fd1ac1e464119e0861", size = 19222061, upload-time = "2026-08-31T19:37:43.754Z" },
+    { url = "https://files.pythonhosted.org/packages/72/b4/28111e86927b2490388d8e0b79ec83db38f2a81bf452318f24563b42e205/ctranslate2-4.8.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e32ae5e625a8999749745de43c2de5c12ceebb6d0d9cc9a3a956e8912bd68edb", size = 1272406, upload-time = "2026-08-31T19:37:45.671Z" },
+    { url = "https://files.pythonhosted.org/packages/03/07/444fccd37ba9e3d84dcb985fc174c82636e7a8c69b47bb0c1bf566045d37/ctranslate2-4.8.2-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:980f13e1d41987908af945861081158fbcb2983f4f9399514adc8c87378b5587", size = 11932080, upload-time = "2026-08-31T19:37:47.24Z" },
+    { url = "https://files.pythonhosted.org/packages/f9/c5/c8d91fa1848f9bc09e943337b9c7ebd20d77f1344f5d595d10979f5e59d4/ctranslate2-4.8.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e866ccf5f5668f7302c39b535d19d7e5811f3f00d6246faf6ed6fa070fa1af3", size = 16884555, upload-time = "2026-08-31T19:37:50.337Z" },
+    { url = "https://files.pythonhosted.org/packages/14/0c/de0305052c2bb35979c4a6c668c7aa6f139c10bc0939833450b462cef829/ctranslate2-4.8.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:573dcf5d96034dd9bcf7ee0db1657af490a52292ffc77dfb4b104da1e93322ad", size = 39521307, upload-time = "2026-08-31T19:37:53.623Z" },
+    { url = "https://files.pythonhosted.org/packages/f2/86/03386a60b634a34c820e00f82bec808d38841cc2c007ec6b797892a9a5b5/ctranslate2-4.8.2-cp314-cp314-win_amd64.whl", hash = "sha256:00bc9f44172d05bd2becefe757d2397ab3292fad1695169f9ebb9c16eb497ce2", size = 19478465, upload-time = "2026-08-31T19:37:56.488Z" },
+    { url = "https://files.pythonhosted.org/packages/33/46/f25a66e905d3ce13aba221e6e7d63e24acd2539e48549675e3e0527f9ffe/ctranslate2-4.8.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:58b1a78d050a7f281907b8acfe6f0cdbbf2feaf7464f38eb05b846f83fbaa297", size = 1295017, upload-time = "2026-08-31T19:37:58.379Z" },
+    { url = "https://files.pythonhosted.org/packages/28/c2/0fc88102f448aee8fa704fec13e568f716df11aa701e901b2e401aa5d8ab/ctranslate2-4.8.2-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:70e2388d31dfb59968e6e011d01a6fc6bf802c0e1ad920a8d2a413f1c9da02ae", size = 11953132, upload-time = "2026-08-31T19:38:00.199Z" },
+    { url = "https://files.pythonhosted.org/packages/62/23/f859ee8af1c6366795d59979a7e941a397823cc38585f5731c34a66f1048/ctranslate2-4.8.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32b9d0e984aa17a0da9b37bb7e4dcc638e898a2c32de08d5a4db53f15f640faa", size = 16872477, upload-time = "2026-08-31T19:38:02.641Z" },
+    { url = "https://files.pythonhosted.org/packages/63/87/b964f427fcdfad983977859386007e1367eb340f747f63b6c0001cf78905/ctranslate2-4.8.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd9ab8b521230e8e962a58362b1ccde582806e781af8c1b9d7b244f3024468b1", size = 39491894, upload-time = "2026-08-31T19:38:06.365Z" },
+    { url = "https://files.pythonhosted.org/packages/6a/39/9d316f00f184cea15e807a977df5bc76fc8f27f81246015f12083f42cd1c/ctranslate2-4.8.2-cp314-cp314t-win_amd64.whl", hash = "sha256:f6f0b576c247984d3fc299a372ccc9319b668d6d25b0539f3c861beba15d0504", size = 19500896, upload-time = "2026-08-31T19:38:09.111Z" },
+]
+
+[[package]]
+name = "faster-whisper"
+version = "1.2.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "av" },
+    { name = "ctranslate2" },
+    { name = "huggingface-hub" },
+    { name = "onnxruntime" },
+    { name = "tokenizers" },
+    { name = "tqdm" },
+]
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/05/99/49ee85903dee060d9f08297b4a342e5e0bcfca2f027a07b4ee0a38ab13f9/faster_whisper-1.2.1-py3-none-any.whl", hash = "sha256:79a66ad50688c0b794dd501dc340a736992a6342f7f95e5811be60b5224a26a7", size = 1118909, upload-time = "2025-10-31T11:35:47.794Z" },
+]
+
+[[package]]
+name = "filelock"
+version = "4.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6f/38/88cd6eda96c40594a1e3da7d8b40f04bc40ace5a6aef9ac5cb407540f173/filelock-4.0.1.tar.gz", hash = "sha256:fdefc3f3e87716d855ae2b732c1cfd521dd99799ef2b4d00e8c0d4dcdc7cc94b", size = 238888, upload-time = "2026-09-19T01:08:15.958Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/29/33/af0635ab07fe83b1788a1dbe370ff3e226062495a998335cb18a1cac81aa/filelock-4.0.1-py3-none-any.whl", hash = "sha256:481a321a27bef441e23c53371c6abc8d7d16e26b97090074ba44f7538a3fd55a", size = 106219, upload-time = "2026-09-19T01:08:14.49Z" },
+]
+
+[[package]]
+name = "flatbuffers"
+version = "25.12.19"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" },
+]
+
+[[package]]
+name = "fsspec"
+version = "2026.9.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/77/cd/9be253869fc42e764de7f3dedd6969af7d44ff9c3375214a3442a6f3fc08/fsspec-2026.9.0.tar.gz", hash = "sha256:0f08147951c8cb31d844c3547d631053b127863b60be04cf06e121333ee0e2fe", size = 333545, upload-time = "2026-09-18T17:50:42.825Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/6c/c0/a98505f18594f1bce828bb159cec0fcf9860562f1a2c85913409fc8f3d9e/fsspec-2026.9.0-py3-none-any.whl", hash = "sha256:8dd6e646e99ea382bd85f97a45e6b526a442d79423a7dc673f1e2756d05fcb5f", size = 221738, upload-time = "2026-09-18T17:50:41.341Z" },
+]
+
+[[package]]
+name = "h11"
+version = "0.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
+]
+
+[[package]]
+name = "hf-xet"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1b/ab/522a2ab67f27971a9d48ca666d4fca85ef7d5282d142e31fd087e27b1bbe/hf_xet-1.6.0.tar.gz", hash = "sha256:2e58454a340b3556dfa4972d5451aff4fba8dd42a236600ba1a1d2b1514f0fef", size = 920527, upload-time = "2026-08-03T22:33:13.243Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/41/62/3c062f593bd92ef4e77a0ef39541e3d82a0a1d3947c8a777a02a13a27828/hf_xet-1.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:70cbb9c896901600128cb9b6f06e132954fbede1db30f31f7c6c63f84cb7c31d", size = 4074584, upload-time = "2026-08-03T22:32:47.364Z" },
+    { url = "https://files.pythonhosted.org/packages/bb/1e/c0ad437dd267a8e435bef594acf781bbc3874ff0b6435b4962d03ecf7cc4/hf_xet-1.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:23379c2f9ec8696d952b16414a2bae72cad86a52df869b050698ba60f538c675", size = 3867381, upload-time = "2026-08-03T22:32:49.049Z" },
+    { url = "https://files.pythonhosted.org/packages/d5/ee/7c0d7b6ab336167531b1c30af2af003f054af4c749becbd7209ae33a77c3/hf_xet-1.6.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f7278c05c22fd60cb436cda1269649b3e81db65ecdc8496e5e164aa4143e7b", size = 4453982, upload-time = "2026-08-03T22:32:50.568Z" },
+    { url = "https://files.pythonhosted.org/packages/63/06/ad8eab1c9525246650cbaa821caa3cdbaca734ab1a5b8c91bea09cbd8d69/hf_xet-1.6.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:948f15d3a9545cfe5932f6bd8b440f6ae630aee108f14b7bd6c561f7c2dcc522", size = 4249445, upload-time = "2026-08-03T22:32:52.391Z" },
+    { url = "https://files.pythonhosted.org/packages/d8/26/1eee8aedb0dafc1ab9717dc9ac602cde33361b232dc06803f1f6ed18b58c/hf_xet-1.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5153e6bb103ad49d6ea9f1b2e230db5a2ea32551ad09a706d2f61d7c7c80d80e", size = 4451099, upload-time = "2026-08-03T22:32:54.114Z" },
+    { url = "https://files.pythonhosted.org/packages/67/57/0b88af1f194ab6c9c650547d9cc06bfeaab836ae4dcdb331676bfb8be95a/hf_xet-1.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:35cec30d75c6f9eb9c16a77cef68e85a103b72e24d4b473714ec9ff06428bab9", size = 4664712, upload-time = "2026-08-03T22:32:55.547Z" },
+    { url = "https://files.pythonhosted.org/packages/53/a0/26b717a9d1840e8abf48dcec64b5ed8fbe472671d38ad28d30e147132b33/hf_xet-1.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5789835d7c6bc9436962853192082374297fb72d7eff7e7762ec25ceb7e25338", size = 4025906, upload-time = "2026-08-03T22:32:57.391Z" },
+    { url = "https://files.pythonhosted.org/packages/49/f6/4a9966633c6fef83af997e2cff68ec1963676d412bdfd096df2a93b8e185/hf_xet-1.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:75765820ce4700db3750c94acc8fe27c5fae4c9ec000a0dbac3ca082acf97765", size = 3849221, upload-time = "2026-08-03T22:32:59.123Z" },
+    { url = "https://files.pythonhosted.org/packages/a2/50/7afa2c9c787405864fc47a0d1bbc02c62e9101947ed43c1f43899fc7d91d/hf_xet-1.6.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:633dc0cd71d32da58ab8c03ad38e2fac452c15c2b0a2866ebf6ededfe0a5061d", size = 4071729, upload-time = "2026-08-03T22:33:00.721Z" },
+    { url = "https://files.pythonhosted.org/packages/4b/69/55b8dcf636142ae660fec1869fcac14c4da2e8412e14d6eee1523be77e9f/hf_xet-1.6.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:f0906082d9932ae0c0057fa194041c22b4e2cdb46b2592ef3b91f020d62a081a", size = 3876287, upload-time = "2026-08-03T22:33:02.251Z" },
+    { url = "https://files.pythonhosted.org/packages/67/4e/a28359bf1c1ecf11eba22123168c138698f7cb576ac678f5a2e16cd5da08/hf_xet-1.6.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d62671bb130879cef0ee4c9ebe47a14af6c66ec53e6d84dc15936e5ffdfac82f", size = 4464663, upload-time = "2026-08-03T22:33:03.802Z" },
+    { url = "https://files.pythonhosted.org/packages/9a/69/1f0cbc2fb22ae6082d094f743d1b8945a3f36f6089cb95f42b7ee348cda7/hf_xet-1.6.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0e6e21fa3cdfcdcd76748564bf593870a5e013f47d97cf10aed63aa222cff5b7", size = 4262538, upload-time = "2026-08-03T22:33:05.287Z" },
+    { url = "https://files.pythonhosted.org/packages/d1/3a/4f4f2301ade26e404462d3336fa11f7958d914cabbabdd6e03c3c5d5658c/hf_xet-1.6.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4fc74352a17015bd0ee90038bc9efe38db894cde45f268b6712b04fce8cd0acb", size = 4460520, upload-time = "2026-08-03T22:33:06.81Z" },
+    { url = "https://files.pythonhosted.org/packages/ab/5f/311725e2a905534dfee2dcb5b08414f249147f1f12252bfc2bd24caa075c/hf_xet-1.6.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8fb4f71cba6129110c3374a33f919001ff130488fc23553698e34cc1c2a1198c", size = 4675937, upload-time = "2026-08-03T22:33:08.616Z" },
+    { url = "https://files.pythonhosted.org/packages/98/b7/8c59a66d15205024662f1d66968136f13893f96df1ddc5087e2e281fc95f/hf_xet-1.6.0-cp38-abi3-win_amd64.whl", hash = "sha256:fb4fadde1b2b70bf4c0c14a6dccbe7194b1c28947fefd5bbe3fed9d940676c3b", size = 4033128, upload-time = "2026-08-03T22:33:10.171Z" },
+    { url = "https://files.pythonhosted.org/packages/73/63/ca511b6f802f28cf3489b280fe77475bcca8de85e81a6299d7916b5b5555/hf_xet-1.6.0-cp38-abi3-win_arm64.whl", hash = "sha256:3dc3e35441ba395006af5aaacc40ef2e603c51ef46c3530b9156185f00935ea3", size = 3859359, upload-time = "2026-08-03T22:33:11.725Z" },
+]
+
+[[package]]
+name = "httpcore"
+version = "1.0.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "certifi" },
+    { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
+]
+
+[[package]]
+name = "httpx"
+version = "0.28.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "anyio" },
+    { name = "certifi" },
+    { name = "httpcore" },
+    { name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
+]
+
+[[package]]
+name = "huggingface-hub"
+version = "1.32.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "click" },
+    { name = "filelock" },
+    { name = "fsspec" },
+    { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" },
+    { name = "httpx" },
+    { name = "packaging" },
+    { name = "pyyaml" },
+    { name = "tqdm" },
+    { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/fe/0f/e83fdd856da8fca26bf78d71709ebd120432a0ce535e72b9597cab1eb5bf/huggingface_hub-1.32.0.tar.gz", hash = "sha256:ed70a45498abe86039df7c2f4e5f7575de524be908d3840e8f828d5525eafd6a", size = 1038662, upload-time = "2026-09-17T10:27:48.049Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/1b/cf/d98dd561d6d0d7b7d7a64d1563f8aaaa7c235daee41c1c9bcc3da62420ed/huggingface_hub-1.32.0-py3-none-any.whl", hash = "sha256:b0c7c80561969d9cdacdd55fce67ba9584cca0b9d4ea80957a3a5c1445fac5c8", size = 842906, upload-time = "2026-09-17T10:27:46.102Z" },
+]
+
+[[package]]
+name = "idna"
+version = "3.20"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
+]
+
+[[package]]
+name = "numpy"
+version = "2.4.6"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+    "python_full_version < '3.12'",
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" },
+    { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" },
+    { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" },
+    { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" },
+    { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" },
+    { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" },
+    { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" },
+    { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" },
+    { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" },
+    { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" },
+    { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" },
+    { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" },
+    { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" },
+    { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" },
+    { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" },
+    { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" },
+    { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" },
+    { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" },
+    { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" },
+    { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" },
+    { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" },
+    { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" },
+    { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" },
+    { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" },
+    { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" },
+    { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" },
+    { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" },
+    { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" },
+    { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" },
+    { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" },
+    { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" },
+    { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" },
+    { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" },
+    { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" },
+    { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" },
+    { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" },
+    { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" },
+    { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" },
+    { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" },
+    { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" },
+    { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" },
+    { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" },
+    { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" },
+    { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" },
+    { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" },
+    { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" },
+    { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" },
+    { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" },
+    { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" },
+    { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" },
+    { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" },
+    { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" },
+    { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" },
+    { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" },
+    { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" },
+    { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" },
+    { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" },
+    { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" },
+    { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" },
+    { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" },
+    { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" },
+    { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" },
+    { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" },
+    { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" },
+    { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" },
+    { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" },
+    { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" },
+    { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" },
+    { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" },
+    { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" },
+    { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" },
+]
+
+[[package]]
+name = "numpy"
+version = "2.5.3"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+    "python_full_version >= '3.12'",
+]
+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 = [
+    { url = "https://files.pythonhosted.org/packages/d6/50/8fdbb16af64895706a45f06a4068e29db732ec180f3c1375f14123359138/numpy-2.5.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb189f09db39283b26bfd061ec16189e14f71c6755207f72a0f7540867afe5b9", size = 16994982, upload-time = "2026-09-06T16:24:29.244Z" },
+    { url = "https://files.pythonhosted.org/packages/60/39/789131c1188c078dcb3a1692e72e1e050c68b88ffe72c9ccaac9bcd7a9cd/numpy-2.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f59a878c33d6b88122d80d239bb3b845d58708750b0cb06a09aebb9b18ec696c", size = 12009327, upload-time = "2026-09-06T16:24:32.491Z" },
+    { url = "https://files.pythonhosted.org/packages/9c/59/a312e95696e5f601914dd8b6dd844692ba61670807417e24b68e337b5c70/numpy-2.5.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a72f874bc9e10e4b8f80426fb49716d5141f64442a0c8418065093ec8017fbb0", size = 5445405, upload-time = "2026-09-06T16:24:35.071Z" },
+    { url = "https://files.pythonhosted.org/packages/30/d0/5623a1707ed4fe16e3909fe3cf5ee3da004ae677ad23d83bbf3adf1a6faf/numpy-2.5.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:fc36dc566135b5eceec4cf89758fcb719266a019ef07dae1754ae7c9f617ef3e", size = 6783213, upload-time = "2026-09-06T16:24:37.253Z" },
+    { url = "https://files.pythonhosted.org/packages/f1/32/84146fc020ad3c25f805f70ab60da46fe3c540a21369754a7e4369754b6f/numpy-2.5.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76c2c1e6bfa5c84adc6434dfbf013aa92096a7985221762c8f11fedfd20fff58", size = 15687872, upload-time = "2026-09-06T16:24:39.751Z" },
+    { url = "https://files.pythonhosted.org/packages/65/af/aa78d1a88805456e212b65461354cd943197fb9acecc4c90fd12295123a3/numpy-2.5.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7e18c623bb5c95acb3b3328861272816ba199fb531921c5d6d0b675f1fde9e3", size = 16717410, upload-time = "2026-09-06T16:24:42.745Z" },
+    { url = "https://files.pythonhosted.org/packages/3b/24/faa79d865e69a97ba17473b23a1b74094b2259c03e820c70297293b9ea49/numpy-2.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4f8929ee6c96bfbd7b4ed2032e0c03af86fe1826740ab61ddabf9072d06e57ff", size = 17040975, upload-time = "2026-09-06T16:24:45.961Z" },
+    { url = "https://files.pythonhosted.org/packages/62/4a/8877e629445a7176297dffcaf9c485faa96a95d81728a62521ad55bd4c0f/numpy-2.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b5d93cf48f687479941d12b69c873ad2cc76bbd487f0091c2200636497f34034", size = 18476479, upload-time = "2026-09-06T16:24:49.35Z" },
+    { url = "https://files.pythonhosted.org/packages/c8/db/35e1c2d38b04cbd5b731f9d71495e055e813197669d22b612f11748d2ff9/numpy-2.5.3-cp312-cp312-win32.whl", hash = "sha256:bf63afbe037eb5d2fe87fbcc7778e61da53ebaf21d938a4515aa73b62532a5d4", size = 6133378, upload-time = "2026-09-06T16:24:51.915Z" },
+    { url = "https://files.pythonhosted.org/packages/3c/a1/accf6d4f0c80c5d9ba9735d6b1550e444180599f34dec69ca01360f717ad/numpy-2.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:0a59a421a32580a009e8a1751345bf829631b990dc1794b80514ab722b435def", size = 12567828, upload-time = "2026-09-06T16:24:54.255Z" },
+    { url = "https://files.pythonhosted.org/packages/22/43/1764aff32e4652526ae2f71fa8b3efd8d25c8a3d6926914454e47138ed1e/numpy-2.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:ccb32e0525d29e8b0572eb84c9a57af0e7a4e615726927506f55063c62414034", size = 10485432, upload-time = "2026-09-06T16:24:57.278Z" },
+    { url = "https://files.pythonhosted.org/packages/79/e5/8fb89cd46d14e35699d13bf943a5f5f441ecee8667120a1f6105ab89e349/numpy-2.5.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:66a78fe4556c60aceda5916f9eacd638b18e9e681016ec302dcb4682d6d4d034", size = 16991061, upload-time = "2026-09-06T16:25:00.411Z" },
+    { url = "https://files.pythonhosted.org/packages/2f/06/9dc9e48b5e5e941c8b10350c5ff2d721da42a20517d911d15544246775ff/numpy-2.5.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92f30e89b8ee0ecf363033576c422b2f58fed6a80bed0aa48dff6d14c654663e", size = 12003676, upload-time = "2026-09-06T16:25:03.475Z" },
+    { url = "https://files.pythonhosted.org/packages/ab/2a/98282aa5b8f58b1157d440bb6282eed47e3632a5de53a714fbab17e659fe/numpy-2.5.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f9a2353b37a1a9e78fd82b27ad7e2a32a2d036604d18f02b05e3136c62ca3b09", size = 5439695, upload-time = "2026-09-06T16:25:05.978Z" },
+    { url = "https://files.pythonhosted.org/packages/a1/f9/b6533d777be9d6ffd29dc1be0867e563e6e8cc9a220ff1b716adc317f060/numpy-2.5.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:ccbc4665079665c3cf3bab4db9f6b095370cd6437d66be549b6c2a1fd19e1958", size = 6779395, upload-time = "2026-09-06T16:25:08.599Z" },
+    { url = "https://files.pythonhosted.org/packages/73/85/735720d04ec197c5dcfacdfc9922667c7f1f5f496a279b7ba4d7c74c4cc7/numpy-2.5.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c76d5dde9f445058f83d0c02af00557a4db91de9a9a57c0df87d1535001d654b", size = 15681750, upload-time = "2026-09-06T16:25:11.173Z" },
+    { url = "https://files.pythonhosted.org/packages/3a/1b/3b16a9bc514a440a7a0883684111dcb1ef1aee960af2ca95da8fc775f124/numpy-2.5.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5fa86b80fd24bcd1aff83ad23be44ea323de3f787be8f8b15d4a65621e25321", size = 16708577, upload-time = "2026-09-06T16:25:14.171Z" },
+    { url = "https://files.pythonhosted.org/packages/69/c4/386f397831b07328b639c96c5b62719346cf4baf07c68d927239752b1534/numpy-2.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd4cb9ad3c7889b9b3fe0a9a9fb5d2ed26f9879bff2608d9f01aed147a20d231", size = 17042047, upload-time = "2026-09-06T16:25:17.582Z" },
+    { url = "https://files.pythonhosted.org/packages/5f/3e/a700ecbf36e85ae8328fd3b0e12eeddc22ed6358a64cb2bd913e0d195d65/numpy-2.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1302b90c0e52281681b2975adfe8a860cb7b12216a27b4b0b4207c44bf7bccf0", size = 18465724, upload-time = "2026-09-06T16:25:20.949Z" },
+    { url = "https://files.pythonhosted.org/packages/41/ee/38e785e88a4045f6ad1d1f2808dcdfafdca48c760260c0587bf171e29fc9/numpy-2.5.3-cp313-cp313-win32.whl", hash = "sha256:1c80eabb4035ecf4ca9cd49cde8a9fdd69a729e63e6474887d1523ade7aa277f", size = 6129003, upload-time = "2026-09-06T16:25:23.664Z" },
+    { url = "https://files.pythonhosted.org/packages/f3/ec/100f2b1794ede74a9b3d7ec6b9736927f56713414c1dfe19ab6c383494bf/numpy-2.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:71cad2b2a7451ab79d8f5e71b453485b6775963d5cf794179144a7463fe6e8ec", size = 12560965, upload-time = "2026-09-06T16:25:26.602Z" },
+    { url = "https://files.pythonhosted.org/packages/80/b1/7dc825ca94c12acebbce4c37caa5e198695eb31424bc579679f32b1bb49d/numpy-2.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:8e4dd766076855b5ff7ea52fa5f07ce26286726e0f8bff446b7739d02e6ea204", size = 10482343, upload-time = "2026-09-06T16:25:29.772Z" },
+    { url = "https://files.pythonhosted.org/packages/70/78/cf416f15dc29375a229d9dfebf8db6e313f291580b39fa1a568b6052bb07/numpy-2.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:350ba9783ce969cf9f7ce6e6a9a58e1a6e2a19ca025b7ee448c4db727706212a", size = 16998686, upload-time = "2026-09-06T16:25:33.171Z" },
+    { url = "https://files.pythonhosted.org/packages/9e/59/abcc2d8def4fd60eec7d87f92d27c13448ffd9ab14339bcc63a0d7a2fdea/numpy-2.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:012e66aca395d795496446e52aeeb5866312a5d4d3f27da270e5a0b43f70dc5c", size = 12013862, upload-time = "2026-09-06T16:25:36.748Z" },
+    { url = "https://files.pythonhosted.org/packages/94/75/4640d2d6e4b64a049e48425a82728a41ef4adb61332d2cba68055774878b/numpy-2.5.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:adc1ada2662f8a5f960b8a10d9986897e7499ef07e06d4cfe7197f8cce923c07", size = 5449793, upload-time = "2026-09-06T16:25:39.476Z" },
+    { url = "https://files.pythonhosted.org/packages/96/cd/625b57ae33d4ca560f32cc0b47b4a5922146d9beb998ddf773900d440a73/numpy-2.5.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:54a115e5a73b8fc44f0cebef486365a1894b5c9760685d4558b72b7c3eb846e0", size = 6785176, upload-time = "2026-09-06T16:25:42.069Z" },
+    { url = "https://files.pythonhosted.org/packages/9c/72/12918652e7912ef9751e8694c88820fcd1908e0618cb23f5f3caa6004b7b/numpy-2.5.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be5a8381859b6da607c84f4f7d6847725f1cf1853ef8a2c9e115b7d58bef47dc", size = 15703377, upload-time = "2026-09-06T16:25:45.135Z" },
+    { url = "https://files.pythonhosted.org/packages/45/8f/9beacf79ca7c650688ad0baa80931adb988fe6e6e5d5903c23cc3dbd70eb/numpy-2.5.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b0521d0f4aebb6e06189451025fa17a913287b13c03d5fe05c017333b654ea5b", size = 16711928, upload-time = "2026-09-06T16:25:48.461Z" },
+    { url = "https://files.pythonhosted.org/packages/09/8d/41d0a56e1ac4c87495c897a211b1368691b7237aadabec8b3b8f3a74d48f/numpy-2.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9deb49575e5b0b94ed72c8a64ec4d033381adc27e9060ae842971f697ba96104", size = 17059507, upload-time = "2026-09-06T16:25:51.873Z" },
+    { url = "https://files.pythonhosted.org/packages/08/1e/0dfbc5cc251d54e2af790f254d24ec38637fa97ec7d5d11de7ffed787098/numpy-2.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b00eefbcf0f292945c4b4dec2ae845389ef5bcdcd596e6e4328051db5b5ba694", size = 18471002, upload-time = "2026-09-06T16:25:55.233Z" },
+    { url = "https://files.pythonhosted.org/packages/b5/2c/dfa40f6991f8185c8c30ffd023dfcbb11888e823cfab9557b920f3bb7bed/numpy-2.5.3-cp314-cp314-win32.whl", hash = "sha256:c2381f82999704f818e2c987a865050e285ec3621262c66d40f5a96c8f899f8e", size = 6180485, upload-time = "2026-09-06T16:25:58.157Z" },
+    { url = "https://files.pythonhosted.org/packages/a4/73/d2c08231e4fde7e415501fd02c715d96e98599b2d8384445933944152984/numpy-2.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:2c25dfa72943e4336ddb6b0ee4277b47a0c85bede0807530ec68103bf58e2c10", size = 12698179, upload-time = "2026-09-06T16:26:00.789Z" },
+    { url = "https://files.pythonhosted.org/packages/5c/e9/dcdcc9b95cf5f49815055573aee1b11cfbf5299f38a180e437ded050810f/numpy-2.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:15aa985ac73a8db02db7663381aa109510449d3819d37206caed27b33a65a8a6", size = 10769383, upload-time = "2026-09-06T16:26:04.011Z" },
+    { url = "https://files.pythonhosted.org/packages/49/c4/af8bc08a7ef4e1529a7c0cf24969accce316b783999802089a581ec99272/numpy-2.5.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ac7bb1c52d445bd4f8f7f97fefe6abc3a084dc4d63df50d79b17fa2b78e89297", size = 12132668, upload-time = "2026-09-06T16:26:07.138Z" },
+    { url = "https://files.pythonhosted.org/packages/c5/ae/0f15eb56d4ec5e13c1f7ff04ff407f997d1acbadb45d3e1f2e2645a8f43c/numpy-2.5.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e6ab667ba76450084eb64013762c438ea76d9d29cc676dcd6c2e9892ba37f841", size = 5568580, upload-time = "2026-09-06T16:26:09.828Z" },
+    { url = "https://files.pythonhosted.org/packages/23/fb/c72a8f25d4b6e96c354e7ab45ace3b27dc11e5d6a13b6c7d0cd6b08bf112/numpy-2.5.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:f7fabeb6cea87d65f3b926de33d03fb016cfdc29314c90974383b5582ae72891", size = 6882634, upload-time = "2026-09-06T16:26:12.524Z" },
+    { url = "https://files.pythonhosted.org/packages/07/a9/968c90ed2ab15060c338e8137f1215b5a60756ae07328e0a60d1c6734df4/numpy-2.5.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fb6f8fb9ff0b3a69f52c66ce397b0246583e9f28616231b0e32ca49259a5fa6", size = 15748923, upload-time = "2026-09-06T16:26:15.092Z" },
+    { url = "https://files.pythonhosted.org/packages/59/08/9df04103947b95e3b6b1f2ed1a70521f325647a31b82da6a2aae3a485508/numpy-2.5.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93e1f5447e2b1e479d7bd74701e84746b86450cff1fc368b132d195e2b8f8211", size = 16746748, upload-time = "2026-09-06T16:26:18.43Z" },
+    { url = "https://files.pythonhosted.org/packages/41/a0/14c8d5fe5b53a334aabb653deb391c0fef49558f491880ea300ed6785224/numpy-2.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c00abe94c1a69d75d827dcf1c025b25c8a45d230b3bcd77a9020883a1b047653", size = 17111561, upload-time = "2026-09-06T16:26:22.113Z" },
+    { url = "https://files.pythonhosted.org/packages/c4/a6/d7e96e42f01522e154c32489640f16dfc4f6181d165d05fc3bec8c2c4999/numpy-2.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:536f963710a4e63934d80ac0dc4f478804a83e9a84b6828018f25d09953ada33", size = 18513945, upload-time = "2026-09-06T16:26:25.401Z" },
+    { url = "https://files.pythonhosted.org/packages/25/39/3453afb7119d0449ef11c886874120ff180e2c337760e0e2d88f70f1a945/numpy-2.5.3-cp314-cp314t-win32.whl", hash = "sha256:4c8a6d2ebce6305fd82fbefca827775437147052a976ee7c94b36a0c1b52ac6c", size = 6335421, upload-time = "2026-09-06T16:26:28.175Z" },
+    { url = "https://files.pythonhosted.org/packages/99/01/22815d2b19a1a746b1d45205cffebb3fe511a18acb75fba6c88491fc9894/numpy-2.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9a37475425b431b4d060f23b4f52cd2f3aef6bc7c654bd760adf0040eec9d435", size = 12896420, upload-time = "2026-09-06T16:26:31.265Z" },
+    { url = "https://files.pythonhosted.org/packages/fa/ee/a7cbba67eeaff038dc29ca8b98a88396c8b0cc9c89d4924f4a27a5c9150b/numpy-2.5.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2d8240cb4c16fd831074aa2b2cf9fc54664d826341d61c372245b96a74a49a9a", size = 10857177, upload-time = "2026-09-06T16:26:34.167Z" },
+    { url = "https://files.pythonhosted.org/packages/45/56/78194492883ff5eec90423fe56a3a44b154da047d88a6307f629713c584f/numpy-2.5.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a6391fafaba97500887132cd582abc6e19452b1ac775a47caa7b24490e152058", size = 16996531, upload-time = "2026-09-06T16:26:37.287Z" },
+    { url = "https://files.pythonhosted.org/packages/11/39/dd55c0af90bbab564b09ae3b0aa60ec5c02b900fa4f1ba23440525c8b32d/numpy-2.5.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:09d5a423c71ad5feb5625844ad58050e35df43871004b52ac9c0ad44a56775be", size = 12012569, upload-time = "2026-09-06T16:26:40.707Z" },
+    { url = "https://files.pythonhosted.org/packages/b6/51/04f67d32e4862b281b1cb84ceeaed3421189a84fb6fb51a391cd6d5009f7/numpy-2.5.3-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:f9579f383d1bf9df80081e72760e84960a7fd4f88cf0c9e535a8597c9bb646f5", size = 5448498, upload-time = "2026-09-06T16:26:43.435Z" },
+    { url = "https://files.pythonhosted.org/packages/a3/c9/25b4dc0dd1344ec26c7319e84fd4e9809d2b5628f4e12decd618036e5178/numpy-2.5.3-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:86bff898a431c0fb71f7610b75726e75a54d47b37edc9d537f48de63bb3c0b90", size = 6783026, upload-time = "2026-09-06T16:26:46.374Z" },
+    { url = "https://files.pythonhosted.org/packages/fc/c7/29285be1e5232a6e7ee3268a33c85843f5a8ee93350c6465cddd66ebbf76/numpy-2.5.3-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f3ed25271581281f2fccb1adcedfcde4c07362eec69189b50baf6f90e3ae159", size = 15697322, upload-time = "2026-09-06T16:26:49.415Z" },
+    { url = "https://files.pythonhosted.org/packages/55/49/bbad5335fb4996a16881f853ff3e0ba582f01720e55c89b1c06b8fc42a90/numpy-2.5.3-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffdc76bfcae6b255dff75202c5e7feaf95b40246bc0a17944facc1fecf9f79ab", size = 16708995, upload-time = "2026-09-06T16:26:53.127Z" },
+    { url = "https://files.pythonhosted.org/packages/ef/e9/1df35483760b04a65ea44669f89dc64f30e5aca098b48ceb8b1310b0e0fe/numpy-2.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:116f96cadd935c6122e9228d676fe7ede19e741f5c8bb1c3cddbe0c51ccebea2", size = 17052508, upload-time = "2026-09-06T16:26:56.464Z" },
+    { url = "https://files.pythonhosted.org/packages/b8/99/66e54da8265cc8be8a7382bf96edce17aaa2837d6f484432025932a3caa5/numpy-2.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:09ffa5d903faeaa5c4dd05009cf81c8bab9f2cb37c548b8d39b65b4cfa7c97f7", size = 18468224, upload-time = "2026-09-06T16:26:59.966Z" },
+    { url = "https://files.pythonhosted.org/packages/01/bc/b5e90a91c115168d793dfd2ad9c69c438c2fe7a13a437e770bc5b078e732/numpy-2.5.3-cp315-cp315-win32.whl", hash = "sha256:e01c918ac3d48e18a927cf7b14a26a3e29ff2bdf2eacb976da0aecd6a43ed034", size = 6179919, upload-time = "2026-09-06T16:27:03.166Z" },
+    { url = "https://files.pythonhosted.org/packages/37/ea/780748fd3985109075514ef8fc64cd25f943e40dde13a6d59141eb268fc8/numpy-2.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:e931e4f499e0dc7ef29d269a8e5b35dd722e5d14be07df6240166ea7c6532fae", size = 12697656, upload-time = "2026-09-06T16:27:06.153Z" },
+    { url = "https://files.pythonhosted.org/packages/b3/16/407be69a2a87c8cab64d95975a8977a426a29e138f07e276ec258f0fe4e5/numpy-2.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:26e15e4aecd8617dfbaecb37d223e365d7b39411fba20454be2670a96aa74cb5", size = 10767601, upload-time = "2026-09-06T16:27:09.297Z" },
+    { url = "https://files.pythonhosted.org/packages/44/bf/a97ffb01e41d50a32a9177aef942a4d0e389a3daf451d04e5f38ef6afb87/numpy-2.5.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:6cef4bb1706dfec49243c05d921eefb4e190d41e2528b30d8035ea1f36b4c24a", size = 17090092, upload-time = "2026-09-06T16:27:12.907Z" },
+    { url = "https://files.pythonhosted.org/packages/d1/24/136c02f2c2af9a067a84d0c3aa10c99012c0476fa5066732fa4a4202557d/numpy-2.5.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:d1c89973648c85069c5046ad460f7b8a00218b29a2e42359ac8cc63e9ab94832", size = 12129429, upload-time = "2026-09-06T16:27:16.089Z" },
+    { url = "https://files.pythonhosted.org/packages/fe/6c/b47582d6597789bf946d5efbeb6b9e56fd8bcbd5efc6fbf51dbe1ea31eb3/numpy-2.5.3-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:214045a5bf00113a146ab9ee9730c44501af6723cdf1f6830932f7b5ef2e7af0", size = 5565452, upload-time = "2026-09-06T16:27:19.868Z" },
+    { url = "https://files.pythonhosted.org/packages/be/b4/ef3cc6da73774202d4deae16bb321fd8298a4e0561e3539f8c4be237d916/numpy-2.5.3-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:8617bbfae4486cf99c9f899966699428d19da931d06ca94ad3da986c76e15997", size = 6876736, upload-time = "2026-09-06T16:27:22.232Z" },
+    { url = "https://files.pythonhosted.org/packages/9e/24/e3813329498596cb842703dcacac1741612ed9fb9c4e6a3e0c7e2ebbc597/numpy-2.5.3-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:595d020938c84e320bcf40ad71089e108eac0d377cd018e14a8c094f39e98d85", size = 15745777, upload-time = "2026-09-06T16:27:25.181Z" },
+    { url = "https://files.pythonhosted.org/packages/4a/9e/4e7a07fd0776dc2210cdacf2010be8665194d094defc10c419d7dea794cc/numpy-2.5.3-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f24021b9f22bc6301c37b196974a92c1c18dccedb6fef3dd252e95f2d6adbe4", size = 16746949, upload-time = "2026-09-06T16:27:28.576Z" },
+    { url = "https://files.pythonhosted.org/packages/91/db/01674c0e20335057813a00c2ebd546ed25bff9ed7914f9bced00f8c55d94/numpy-2.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:71b39d9f935b6ec0f8753e3e2afb51e3efba6f2e05b68b32a40754d24bcd4a3c", size = 17108994, upload-time = "2026-09-06T16:27:31.946Z" },
+    { url = "https://files.pythonhosted.org/packages/45/7a/584c5e71f8d378e57cac0b033891ed65c683ef90573ba4854e8c28203db0/numpy-2.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:6b05c171afb3aa07adbd20abc00aea86fe375beb0fdb9ef780ec5b7f63bab1c0", size = 18512266, upload-time = "2026-09-06T16:27:35.196Z" },
+    { url = "https://files.pythonhosted.org/packages/a1/d2/4e1014173aa3c55e6a756e0e567290743a6ab33a288460374d7ef6bcd239/numpy-2.5.3-cp315-cp315t-win32.whl", hash = "sha256:f54660b0eb6b0b9f36e7fe1cdfdff472028dd0d14acd9b9b65098efbad059469", size = 6330292, upload-time = "2026-09-06T16:27:38.149Z" },
+    { url = "https://files.pythonhosted.org/packages/6c/b0/ff5658a58199b7bcaad87bf260eef6713d9d42cca4e028f935b4fc5fbac6/numpy-2.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:1aad64d99730d013cfc6debafed22783b4fc5a7f4b8bc744d2d8cf7dcc880551", size = 12884918, upload-time = "2026-09-06T16:27:40.965Z" },
+    { url = "https://files.pythonhosted.org/packages/fb/0b/b12a2df5d1b774bd9007a6fdff9381145b6223d37f11afc9c37ab0efd9a1/numpy-2.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:befa1ae5bd6030b3f512b43ff3fa5290bbed6b84411a44244b14adf835f5b89d", size = 10850807, upload-time = "2026-09-06T16:27:43.868Z" },
+]
+
+[[package]]
+name = "onnxruntime"
+version = "1.30.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "flatbuffers" },
+    { 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 = "packaging" },
+    { name = "protobuf" },
+]
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/73/b6/e2fa6dfc8c3cd19639d452c3120fb0e02f5176b4e24ecabb24b5362be725/onnxruntime-1.30.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:f36c87ec504f4f328e968ee322e4ba9682abe66280b3425625afcbed5333fed7", size = 21533037, upload-time = "2026-09-10T16:31:03.582Z" },
+    { url = "https://files.pythonhosted.org/packages/4f/1a/4d197d2676a866f6239cd2d06a95b6db40d47dc29d5bec9fb1493a13dd94/onnxruntime-1.30.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:bac3aee7b40acd805659fd81a2acd6096312d5b0f014c97ddfe75fb4c1644627", size = 21332697, upload-time = "2026-09-10T16:31:08.118Z" },
+    { url = "https://files.pythonhosted.org/packages/4f/2e/4c96278a99140d0307ccda6fd51e6e9d1ca7c9f6fdd1fa96ff375acfc713/onnxruntime-1.30.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:fd54b314ea385bcecac69ab431f020ba503e3878dad4ebb645fec5a24b041242", size = 23561046, upload-time = "2026-09-10T16:31:11.494Z" },
+    { url = "https://files.pythonhosted.org/packages/66/68/b8aaade8c1883f569228f4b57201cce922bb7d4af6f977d619520a733430/onnxruntime-1.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:0edd0145a6e3fce8a1276491dc784d615e3c58bcb952c9b4e5c876d5c6a12ad7", size = 14309136, upload-time = "2026-09-10T16:31:14.181Z" },
+    { url = "https://files.pythonhosted.org/packages/96/f1/fbda6fb08ac4db6c5c6f7c3e128e4d8fb398b52fe2fc3282c45cadedd18b/onnxruntime-1.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:16506cfa5d218850f2b46e0edce1b952121f2f84f8e0074dafe15f4787f0c8fb", size = 14167408, upload-time = "2026-09-10T16:31:16.505Z" },
+    { url = "https://files.pythonhosted.org/packages/31/6f/48169f2e62b405bff5053cbd1d73fb5ce41ef7ecd13bb3bfcc191e689b8a/onnxruntime-1.30.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:001ed726c9bd5e2bc92faade7d37d889e9606a350b7d5529f0227df2e3bb57fd", size = 21544867, upload-time = "2026-09-10T16:31:19.876Z" },
+    { url = "https://files.pythonhosted.org/packages/16/bd/cbc5b8f91963689fdd622f463508c01d0aa95d3f944747b1e0b1eb2160b8/onnxruntime-1.30.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:6c32a000d5139a38ba9349030b0032e3331acb559d596b22738d9d2b343a2b83", size = 21345202, upload-time = "2026-09-10T16:31:23.361Z" },
+    { url = "https://files.pythonhosted.org/packages/34/35/e7f862dbacbc99fadd9b14a614e49c99bf0f35fd9927a82f096e3de33531/onnxruntime-1.30.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:fa688e7891a6aa206636fe7372e27ee75fd17713289f6b4fc7b190e0a7de9328", size = 23585654, upload-time = "2026-09-10T16:31:26.65Z" },
+    { url = "https://files.pythonhosted.org/packages/a6/13/0f1699f6de549c9324bc9112a2a85b14c517904cd11b562a654643b755a1/onnxruntime-1.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:f3501472571f1b1eee50e017851e7929f5ea37312d2d8c2494a19e8fc58b4a38", size = 14311470, upload-time = "2026-09-10T16:31:31.273Z" },
+    { url = "https://files.pythonhosted.org/packages/a2/17/02b13e5f51461f0453b18ab854e2d0bc1b6ec353241b05a2ee6b79e35d87/onnxruntime-1.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:dc4c706f1935ebb62356e6a095b047859badd854482c40560888e95c328ed262", size = 14175072, upload-time = "2026-09-10T16:31:34.541Z" },
+    { url = "https://files.pythonhosted.org/packages/f0/75/508454c5d01f31641dabc597fe559594c931a520a2673031179319d0afd8/onnxruntime-1.30.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:05e4fc41711d1f4abd19a9124b5be7a65a506cb2670a7f14b16162ef13c58134", size = 21544990, upload-time = "2026-09-10T16:31:37.685Z" },
+    { url = "https://files.pythonhosted.org/packages/89/06/e603c71f43f4fe3fd156a053af79cbed6e27a2c649f0988a67d97fedd39f/onnxruntime-1.30.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:5327cf6aa15a02bad805fac8bd6882a62571e8b72f6f2938a8f37e6bd1966ce9", size = 21344996, upload-time = "2026-09-10T16:31:40.915Z" },
+    { url = "https://files.pythonhosted.org/packages/f1/a1/ede48ab5dc54907a2999362777f541e132639fb06628ded1932058aa8a36/onnxruntime-1.30.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:86f940afc801ea9681a4da8af84fbe95e1d9ea7d80903952cc1bfad54faad38f", size = 23585560, upload-time = "2026-09-10T16:31:44.941Z" },
+    { url = "https://files.pythonhosted.org/packages/3c/dd/c57c529dbc6dd55eca24b12cfbeab1b6a690de72083824eca689085f55b0/onnxruntime-1.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:4b63041bd623a9a9ac5e353948436c6fa7f43edd12d6b4a4ebc340bca959ba93", size = 14311378, upload-time = "2026-09-10T16:31:48.034Z" },
+    { url = "https://files.pythonhosted.org/packages/11/2f/ef00b45b911e7f2115273a24ed1b43e73c8c6c9ec17f9bfa13f8492581f8/onnxruntime-1.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:c389b6887fc95e0fcb80e89b156bc2cb18e662c29df55e9326fe64140e7d7b4f", size = 14175134, upload-time = "2026-09-10T16:31:50.799Z" },
+    { url = "https://files.pythonhosted.org/packages/92/0a/284fd6fe701c9a8aff39dbc119c37582ca40e9cff1ea05425a7cc8606a02/onnxruntime-1.30.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:e5224ba2b00284cb1c48b3edcd303c109245d66df9ec1b861858fd6de672a38e", size = 21349453, upload-time = "2026-09-10T16:31:53.898Z" },
+    { url = "https://files.pythonhosted.org/packages/d6/72/4f4466f8fa1ec267a9ef5e2f3bd175c203d3da0facbdf4049dc93abbe91c/onnxruntime-1.30.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:3f9e002417f1e3bbb31ed43dafa4f22ca1b2b68832244fdee192fcaa1ae19bca", size = 23579837, upload-time = "2026-09-10T16:31:56.82Z" },
+    { url = "https://files.pythonhosted.org/packages/6a/03/05c9a9234688757d2876ddecf80bba908561ee11debf125bc1a427ae6f48/onnxruntime-1.30.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:8b6169c16a48429890d2f4a0c774ebf54dfe9066a998514aad0518a16d398547", size = 21545313, upload-time = "2026-09-10T16:31:59.654Z" },
+    { url = "https://files.pythonhosted.org/packages/c6/bc/1069e58b24779ba9d2fd479db5ecb3a15a6f49b585107c898819c0789558/onnxruntime-1.30.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:d2184fddb6798136e7c478244391ca82443f5c757f59f15bb9e5ad2da5e03175", size = 21346830, upload-time = "2026-09-10T16:32:02.275Z" },
+    { url = "https://files.pythonhosted.org/packages/f1/38/8138eed225c5bc6ddfc05879ecac7dacc63c34b9b6f99be72839c1f6dc49/onnxruntime-1.30.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:8b611d24db2954545ce6bd9acd4670183cb368e4642450de7a9ab6474eb374ec", size = 23586333, upload-time = "2026-09-10T16:32:04.996Z" },
+    { url = "https://files.pythonhosted.org/packages/4d/0a/748b86000fbf9518b5dfc5bdc1b924eeaca976203740fd9d30649e568ee6/onnxruntime-1.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bdd1752a8502ac1a7ccc6e878d16db6943df574d54ed7f01058a6806ae05be4", size = 14675319, upload-time = "2026-09-10T16:32:07.5Z" },
+    { url = "https://files.pythonhosted.org/packages/2f/db/db8e4c0cf6f70f1311060560630fd21763648e01dfbba5195a2592c052ef/onnxruntime-1.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:83d543843cbd352cfa9996a6c7b92f8a480f114a18f95ff2a1acaf6921e85b6d", size = 14569360, upload-time = "2026-09-10T16:32:09.896Z" },
+    { url = "https://files.pythonhosted.org/packages/24/0a/ec0a9d656e39b43887c378a5388b20c3b1e1ee43bded84580b0936466694/onnxruntime-1.30.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:265de607ba6f9814264e1d5d413fa7069d70f48e3049b5e460b3b04bdfcef294", size = 21348433, upload-time = "2026-09-10T16:32:12.772Z" },
+    { url = "https://files.pythonhosted.org/packages/91/f0/40f74b7c00077e1e25627067ed98a70df1fef5c0e21b82849190d312554e/onnxruntime-1.30.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:67ad7f03433b6462c627d0f555dece80e6a26bc71e8542ced35cebd32142d1b7", size = 23579340, upload-time = "2026-09-10T16:32:15.532Z" },
+]
+
+[[package]]
+name = "packaging"
+version = "26.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
+]
+
+[[package]]
+name = "prompt-toolkit"
+version = "3.0.53"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "wcwidth" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7d/ea/39b988c938f75cb75d7045b5c69f8bfed47ee2152c8837fb403de29d6fb8/prompt_toolkit-3.0.53.tar.gz", hash = "sha256:9ec8a0ad96d5c56148b3f914aa79c1564c3fde5d2e6b876e7bc327e353cf8fa6", size = 435492, upload-time = "2026-07-26T20:56:14.758Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/54/6f/84908cad2d6aa5144abcf7b42709fe4fdb459bc640ec7ac5786e7693dabc/prompt_toolkit-3.0.53-py3-none-any.whl", hash = "sha256:01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2", size = 392288, upload-time = "2026-07-26T20:56:12.512Z" },
+]
+
+[[package]]
+name = "protobuf"
+version = "7.36.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d9/89/5b8517baa72f84a67b8a307ba953c91057af618bf40bf676f3c03551f8f0/protobuf-7.36.2.tar.gz", hash = "sha256:497d0463ff3316681da6c0b9e8d06cb465d61abce00b613ab42226175644d1bb", size = 512737, upload-time = "2026-09-17T20:07:59.326Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/32/72/98342feb672507c8f3a69e34b4fa8961f608edba5c1a48a6f47156d92cb5/protobuf-7.36.2-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:cbc70b17ee27e28894c7fee8bb04be1abead49e936bc70eb60052531eee2079e", size = 456039, upload-time = "2026-09-17T20:07:51.542Z" },
+    { url = "https://files.pythonhosted.org/packages/b6/ea/91fdf7c2b8bbd49cde056f00a9df6773532987e1c00fe2830b895af95c7e/protobuf-7.36.2-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:e11e1f0180583a2af89db6a2ecd9e8dc40aa6d2988ca175bfd0e6d12ea72d74e", size = 344219, upload-time = "2026-09-17T20:07:52.914Z" },
+    { url = "https://files.pythonhosted.org/packages/17/ab/5fd5f8ece73fad885c5a09aa849b32d70472f954ba3a92d3bb5974ea953b/protobuf-7.36.2-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:f4fee11ec330d238b34a05c9b675f693c20415d1c5bd7d5320cc2f8a798eb9cf", size = 357223, upload-time = "2026-09-17T20:07:53.985Z" },
+    { url = "https://files.pythonhosted.org/packages/db/f3/3996583dd2906297a637af12114deddf7658af6e683fedb83be061983fb5/protobuf-7.36.2-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:89f23aa53c24553a2416fd4fd1ec06f74fa42b14b546d8883128813f775bbfd2", size = 343223, upload-time = "2026-09-17T20:07:54.931Z" },
+    { url = "https://files.pythonhosted.org/packages/fc/1b/dcc64f358fcb51811b58ae40b3d28f820725f116d86487cc20bd4b130701/protobuf-7.36.2-cp310-abi3-win32.whl", hash = "sha256:912c1221170e16c08d1f086762f563dd61ff83c18b5fa6652952dfaded66f728", size = 442998, upload-time = "2026-09-17T20:07:55.826Z" },
+    { url = "https://files.pythonhosted.org/packages/8a/55/b77bda4e5e5f5971fb51b07663694690e9afdb9402136c16a522bd621cad/protobuf-7.36.2-cp310-abi3-win_amd64.whl", hash = "sha256:a300819d441e078a5608c0d3c709796bb548136058fda017ae51d425b44fd353", size = 456514, upload-time = "2026-09-17T20:07:57.188Z" },
+    { url = "https://files.pythonhosted.org/packages/e4/04/d52c7016b04b6c5108f26691f9d33ec82a9b65d041f1a9c771137693d618/protobuf-7.36.2-py3-none-any.whl", hash = "sha256:bdb3a345d48db958e6ce1f18e508beb0cc981d64f24088427549c866cd039f1e", size = 179806, upload-time = "2026-09-17T20:07:58.211Z" },
+]
+
+[[package]]
+name = "pycparser"
+version = "3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
+]
+
+[[package]]
+name = "pygments"
+version = "2.21.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
+]
+
+[[package]]
+name = "pytest"
+version = "9.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "colorama", marker = "sys_platform == 'win32'" },
+    { name = "iniconfig" },
+    { name = "packaging" },
+    { name = "pluggy" },
+    { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
+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 = "pyyaml"
+version = "6.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
+    { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
+    { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
+    { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
+    { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
+    { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
+    { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
+    { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
+    { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
+    { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
+    { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
+    { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
+    { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
+    { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
+    { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
+    { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
+    { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
+    { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
+    { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
+    { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
+    { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
+    { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
+    { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
+    { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
+    { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
+    { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
+    { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
+    { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
+    { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
+    { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
+    { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
+    { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
+    { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
+    { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
+    { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
+    { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
+    { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
+    { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
+    { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
+    { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
+    { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
+    { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
+    { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
+    { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
+    { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
+    { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
+    { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
+]
+
+[[package]]
+name = "questionary"
+version = "2.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "prompt-toolkit" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" }
+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 = "ruff"
+version = "0.16.8"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ba/78/449cb84790bd5cc3823b2652ee405a4558856e5c4195aee3a16bf7b3eb5d/ruff-0.16.8.tar.gz", hash = "sha256:9247bf92b5f04d825c8639a4fe423ec2e4222acd9222e58412b0dab7e442798b", size = 4938814, upload-time = "2026-09-16T15:54:46.688Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/ac/25/6071aabc530e9be7e2c195e8fe3f7aea2735405b6cf447212832d7811831/ruff-0.16.8-py3-none-linux_armv6l.whl", hash = "sha256:6ffbd6d87383c1edf5f6fa890f10200950240d7c1a16052a19a09d3a2307dd38", size = 10048966, upload-time = "2026-09-16T15:53:57.605Z" },
+    { url = "https://files.pythonhosted.org/packages/54/98/07f90ecbc74dd5fb5764f11f2bc774d6a7cffef92d2ff5f5b4e9e23c754e/ruff-0.16.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:42ed6b878ed61e3acca92f2730a17acff39286944ea82398544696366a6f925e", size = 10165498, upload-time = "2026-09-16T15:54:01.14Z" },
+    { url = "https://files.pythonhosted.org/packages/fe/1f/e6a712e3b47cad4a40600134105ed193cb773f618a42eb7ba323cb812cc0/ruff-0.16.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ea781c7f2afba8c6a505ea0fb3f994020249e0c450635f5381286fea6b46170", size = 9830004, upload-time = "2026-09-16T15:54:03.998Z" },
+    { url = "https://files.pythonhosted.org/packages/23/f2/311a08776d75d81c7676e20b6b020ae63cbe881fcdc7a8dd64e6e18bdd93/ruff-0.16.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8efeae3bbe414a5efefda11a792dfb51ef90ac48d50c4830de2f644caf3e8659", size = 9986558, upload-time = "2026-09-16T15:54:06.804Z" },
+    { url = "https://files.pythonhosted.org/packages/f3/ed/37b6cb3d3ba8c73e68ae3eb1d502383beb5aa05a582bb7bb3a922f929f54/ruff-0.16.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a79b795469fef7fc6e908b218eed2eb17332afd85031db6480dc864560e69b2", size = 9877332, upload-time = "2026-09-16T15:54:09.552Z" },
+    { url = "https://files.pythonhosted.org/packages/22/cc/40873a8f36ad084cc540d55fcca7077264d5b13b24659e9180c176fb2b08/ruff-0.16.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fdc5563cdc50555e6fba39322850860e9267c1b3d12c26a74729d8604c3c812", size = 10507125, upload-time = "2026-09-16T15:54:12.152Z" },
+    { url = "https://files.pythonhosted.org/packages/c3/e4/fc91a642b78ccbab6b9477720f3644ae7a10a9bcce69a934679cd64f62bc/ruff-0.16.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34508983c70665578dab88f5223d8e6228307e1135398ca8bfc8b7e9501e282b", size = 11336694, upload-time = "2026-09-16T15:54:15.489Z" },
+    { url = "https://files.pythonhosted.org/packages/c2/3d/bbd2a9a600a4e73dc3e7548a249c8d1671273464b55822c6fae50f602dff/ruff-0.16.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:644bb578569e0ffc575741232bd385dacdd6fbe123f1a729e7a225f54aa3957f", size = 10774448, upload-time = "2026-09-16T15:54:18.16Z" },
+    { url = "https://files.pythonhosted.org/packages/1a/41/d83af9879a7b6e8bf5fe16b1da0b134049d2f5d3afac12defb0897cb84bd/ruff-0.16.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15e7d226246961db9235098333caa13063906d3851136b84c2900b82f5daa1df", size = 10323796, upload-time = "2026-09-16T15:54:20.743Z" },
+    { url = "https://files.pythonhosted.org/packages/f5/2c/cefd07bfe914b84943ea769ade8d607bd22750b965d3228eefd7cebd15d0/ruff-0.16.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a2bf6bc3e9ebdd4449abc6f06cf64b98051a2c61cf94d2fe9596518c881f1a1e", size = 10514115, upload-time = "2026-09-16T15:54:23.497Z" },
+    { url = "https://files.pythonhosted.org/packages/f3/9d/76a2e26c79a23be6e6e3664c57bec9e9fc8de155cfb9e4b67ea91b64f9d7/ruff-0.16.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6ca111ba0849539165e9e59d2b442542f3c1e8060ebbdea82494f1ffbccb1e1f", size = 10072582, upload-time = "2026-09-16T15:54:26.185Z" },
+    { url = "https://files.pythonhosted.org/packages/2e/d4/f42edddb39668af1a559ceafa3823aedd65633a48dc9768e775485faa2c1/ruff-0.16.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:359a1e5b495448ee1e91018064382ebc86f90e8aac2fed222c7d0e4e8df85fd2", size = 9879644, upload-time = "2026-09-16T15:54:29.278Z" },
+    { url = "https://files.pythonhosted.org/packages/f8/d4/913e3195d95e0378786c6656945c865f534a3560e29139da4882aff630d1/ruff-0.16.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:59e8f5681349474110b24d62e93cfda6593f5fa3473446ca3705200cac1a08b9", size = 10231569, upload-time = "2026-09-16T15:54:32.036Z" },
+    { url = "https://files.pythonhosted.org/packages/2b/c4/8aa6ea0bdcedbd1bf87397e2fc4ed8406448ea5842f8660bc6e5f163039d/ruff-0.16.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:efa3e7a16d1baaa79957888dfdf8be9ef2e44db81cb032af06d76632ab59e773", size = 10663666, upload-time = "2026-09-16T15:54:34.838Z" },
+    { url = "https://files.pythonhosted.org/packages/3d/02/7f10ef4700bc223c30a3fdd10631a29830c45524b810a3c7ed947af64591/ruff-0.16.8-py3-none-win32.whl", hash = "sha256:55793ba85c69921e89be061426d91a78652d6e50317c962240922747a4eb713f", size = 10093472, upload-time = "2026-09-16T15:54:37.47Z" },
+    { url = "https://files.pythonhosted.org/packages/1e/5d/a509c07d714b6da88f2c518b4637cf6f1d46b074be8f0f1e5fb9ff5126fe/ruff-0.16.8-py3-none-win_amd64.whl", hash = "sha256:a6b85621fd3c81e31fc5f5add09c9c078b430db3595ca632efafdec9e64ebfaa", size = 10586899, upload-time = "2026-09-16T15:54:40.488Z" },
+    { url = "https://files.pythonhosted.org/packages/fe/a0/50787329e4f20bf9dc9f6230015d46ec69c51a97ace5bc202dae4755365d/ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a", size = 10386316, upload-time = "2026-09-16T15:54:43.332Z" },
+]
+
+[[package]]
+name = "sounddevice"
+version = "0.5.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "cffi" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ec/db/0c890e2d9aab9ba284021efc02e1d3aebfecab1b611762d7434602209bcf/sounddevice-0.5.6.tar.gz", hash = "sha256:8ec9fbfde2e32f020b167e348f3ab3bac6625a5f15af524d790108ac7147a410", size = 1120094, upload-time = "2026-08-17T07:55:05.048Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/72/1f/62eef605172bddc1017508469a12f75bc7c4194ece35c734f822795f53b1/sounddevice-0.5.6-py3-none-any.whl", hash = "sha256:de099612311ad81e55d31ccbd83f43ea6bf4d87b48f9b6ea55a1fbcde0eee4e0", size = 32793, upload-time = "2026-08-17T07:54:57.507Z" },
+    { url = "https://files.pythonhosted.org/packages/b6/84/85e719d49cf98b2f406d9ac9c338892286c4448eb42ef0b2625ccf159616/sounddevice-0.5.6-py3-none-macosx_10_6_x86_64.macosx_10_6_universal2.whl", hash = "sha256:e3aef00ad8b1d1740eb66d9a7671eab88a4d2b8fa4ab33498d742e63b65c309c", size = 1009647, upload-time = "2026-08-17T07:54:58.814Z" },
+    { url = "https://files.pythonhosted.org/packages/c5/6f/6292145099f72a153a710245f46ae43e5fb6c77bec1b6086cb76c12dc280/sounddevice-0.5.6-py3-none-win32.whl", hash = "sha256:b36b807eb02abd257198bf84b2af05e4fea199a9d2f0019014169c7136d45e9c", size = 1009627, upload-time = "2026-08-17T07:55:00.401Z" },
+    { url = "https://files.pythonhosted.org/packages/8d/3e/cbc593c31a5f0d817b3fe97e64aa8461bd0f55cb07b67ce1b776296ae336/sounddevice-0.5.6-py3-none-win_amd64.whl", hash = "sha256:7f4162f514f007b0bf25a3ccfed3f1705bc2ec311888a90232729eec4f57a4f4", size = 1009630, upload-time = "2026-08-17T07:55:02.088Z" },
+    { url = "https://files.pythonhosted.org/packages/60/a4/b0c21c9f215a6fd9606b8f8748c21212dc098e5d5a2d93068c50edcf19b4/sounddevice-0.5.6-py3-none-win_arm64.whl", hash = "sha256:c8ae19173e5f27f8c12d4b5eee2dbfe542cee125d591e663e0fb4dfb75246d45", size = 1009630, upload-time = "2026-08-17T07:55:03.689Z" },
+]
+
+[[package]]
+name = "stt-daemon"
+version = "0.1.0"
+source = { editable = "." }
+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 = "questionary" },
+    { name = "webrtcvad-wheels" },
+]
+
+[package.optional-dependencies]
+portaudio = [
+    { name = "sounddevice" },
+]
+
+[package.dev-dependencies]
+dev = [
+    { name = "pytest" },
+    { name = "ruff" },
+]
+
+[package.metadata]
+requires-dist = [
+    { name = "faster-whisper", specifier = ">=1.1.0" },
+    { name = "numpy", specifier = ">=1.26" },
+    { name = "questionary", specifier = ">=2.0" },
+    { name = "sounddevice", marker = "extra == 'portaudio'", specifier = ">=0.4.6" },
+    { name = "webrtcvad-wheels", specifier = ">=2.0.14" },
+]
+provides-extras = ["portaudio"]
+
+[package.metadata.requires-dev]
+dev = [
+    { name = "pytest", specifier = ">=8" },
+    { name = "ruff" },
+]
+
+[[package]]
+name = "tokenizers"
+version = "0.23.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "huggingface-hub" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/18/1e/bc6587c5ab643b2e17776cace9070a2ae73549c86bffac9934a600bf3c31/tokenizers-0.23.2.tar.gz", hash = "sha256:7f0f085686b9de0d0079e6f874ae053600db64c5d13049e0bbc0119926d25aac", size = 385745, upload-time = "2026-09-03T08:55:42.89Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/4d/ed/8a443528baa6fac8dfe8c3b75b038c63ac92bb539bcabe311e227c718173/tokenizers-0.23.2-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:85a9a357a3764aecc904ee76bdaf8cf1ad8e5a67a1b929a487c4a39b49ed0e90", size = 3148852, upload-time = "2026-09-03T08:55:30.874Z" },
+    { url = "https://files.pythonhosted.org/packages/67/49/22da045a91732384d3a3771816bf188dc5a1f702c32e635afa7c679c0bef/tokenizers-0.23.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:986670e43691469dcee610ea0f846f91a8f84e91fc6f7a48d4c064414c0ec2bf", size = 3101593, upload-time = "2026-09-03T08:55:28.587Z" },
+    { url = "https://files.pythonhosted.org/packages/2e/4d/8f569ed49372a3ed8e57099bd515055fd48d7c95912c4307cda6973c2168/tokenizers-0.23.2-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a37039b5dfc4af84eb3ef0a92f4307e28936c8f9adccba2629d36f652e9bf7a2", size = 3516830, upload-time = "2026-09-03T08:55:14.741Z" },
+    { url = "https://files.pythonhosted.org/packages/2a/de/e2f14c8919d5bf51874051d00d6c7b7e0e8bde6c6a2dbeddda7f642896ff/tokenizers-0.23.2-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b7e37ba198f24150f523e1242e83c4970de4a525480586be5dcc24d9add32c5", size = 3407975, upload-time = "2026-09-03T08:55:16.842Z" },
+    { url = "https://files.pythonhosted.org/packages/c5/bd/93c69152d02ef06ce47aed8b2bf4952dcf733c935a62791873932b2934d9/tokenizers-0.23.2-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43e4f2071e3cc8d5d86421c874aebc82659bb51a68bcdef5a0da75ee89511ccb", size = 3748165, upload-time = "2026-09-03T08:55:24.769Z" },
+    { url = "https://files.pythonhosted.org/packages/2d/b7/56b84b80bc96942bba8eb23751a9e8a1fce4faaf4390425e7083f721c98c/tokenizers-0.23.2-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:325fee2e0418a9dc6c9ecf736a5f5f0db7875183ace9549ae339da76f7a1fbb7", size = 4024165, upload-time = "2026-09-03T08:55:18.806Z" },
+    { url = "https://files.pythonhosted.org/packages/9b/8a/0175e216f005c2fe08238292663aa41e4c802b216e71047a69a0e9fc6fa3/tokenizers-0.23.2-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:950d7c9426fa72406a0ffeacdbc0bb9985f5db20eb8b263f29c79aaf83105703", size = 3591899, upload-time = "2026-09-03T08:55:22.752Z" },
+    { url = "https://files.pythonhosted.org/packages/2c/ca/ca6b93c7820df123b2662a9469e8facc826ccc94e98fdd0d615f6431e73a/tokenizers-0.23.2-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:41c2f84d172449b4dadb9cdc508e3e364076613c35b16e76ecfe47a60d1e3305", size = 3386843, upload-time = "2026-09-03T08:55:26.584Z" },
+    { url = "https://files.pythonhosted.org/packages/e9/a4/4f9106d317b14a80aefea9f0e3a8d07ef25f856a7607eb7f5ab894281fcb/tokenizers-0.23.2-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:12f0835dc2ee694746a76adf7b1567d4346a4a502ebe93fb1f5f80ea49799b78", size = 3577314, upload-time = "2026-09-03T08:55:20.825Z" },
+    { url = "https://files.pythonhosted.org/packages/8d/6a/1552b70fb0d9ab074fd3fc961435d01364e79c9058481822c3af6e8d402c/tokenizers-0.23.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:eb2f9c8a24da020ea8c11a01a19c1c2547912d92121ae4a01cfbca46125dee40", size = 9967367, upload-time = "2026-09-03T08:55:33.188Z" },
+    { url = "https://files.pythonhosted.org/packages/06/01/3ccb3a956c7528b2507b8a9714155c4baf86af593039db6ea375dd0c96c3/tokenizers-0.23.2-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:f486f402f6f9abee5bb032553736813af0c710a86b2e0ca592634c55cea1f835", size = 9811886, upload-time = "2026-09-03T08:55:35.642Z" },
+    { url = "https://files.pythonhosted.org/packages/fa/73/7038e612d48bda1599457f712f6bd3854eae1a9dc9c13aa47f835349db48/tokenizers-0.23.2-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:bef235815a067b2648caf6dcc7a71091b0b0fff9ee8057f6451eb9335fae52ef", size = 10146224, upload-time = "2026-09-03T08:55:38.391Z" },
+    { url = "https://files.pythonhosted.org/packages/b5/d8/8e9e4e0b287a338d8f88976729628c9d22e8a54cfaf9777018a7f7cb58a0/tokenizers-0.23.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5c56bda1511921587789163e524d196ed8284174ac23abd7685d5ea8da6c4718", size = 10256304, upload-time = "2026-09-03T08:55:40.977Z" },
+    { url = "https://files.pythonhosted.org/packages/f3/1f/c79a01f671a49728ebb0b61f7ff9ea45663b66cab40bc0858e9859b25c16/tokenizers-0.23.2-cp310-abi3-win32.whl", hash = "sha256:debf978920d93ba9c219bd67cc4bbfaf912c9039e41e7a28b91ec15e3728c95a", size = 2592809, upload-time = "2026-09-03T08:55:48.02Z" },
+    { url = "https://files.pythonhosted.org/packages/db/f7/0a69ac6b82dbccf3f71add938a161c497952749294b8dd6dfe03a819dc40/tokenizers-0.23.2-cp310-abi3-win_amd64.whl", hash = "sha256:2e96f5699d5249c9c64aa8412e044f727aae3a4098cf830f9901ec1afc361cde", size = 2863236, upload-time = "2026-09-03T08:55:46.193Z" },
+    { url = "https://files.pythonhosted.org/packages/d7/b0/dee84cb44175be1b4c35bd2f770727494e78f0bb38e571a623ade94dbebb/tokenizers-0.23.2-cp310-abi3-win_arm64.whl", hash = "sha256:e49c394456dd9985787fec76132438ba3fb8911f857b1bf3d40119f9292d41aa", size = 2729352, upload-time = "2026-09-03T08:55:44.345Z" },
+]
+
+[[package]]
+name = "tqdm"
+version = "4.70.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/0d/ea/b2a5bd54b28a324dae8211928b2d730b6547500342c7e6c6dea08bd0a485/tqdm-4.70.1.tar.gz", hash = "sha256:cefd0eca11b2a37a3aee776544d4f4ae913f02688135b5556b8788dfa474afc4", size = 171846, upload-time = "2026-09-11T07:25:16.601Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/a7/03/921a3d3c75785aca9ebfbfcabfbc3a1be12e2ab5265deb026d55a5a3f83e/tqdm-4.70.1-py3-none-any.whl", hash = "sha256:c293e525e6fef9c20e8728fd4612df02a0aa31bb5fe91ecd93e123b1b7bffa73", size = 80199, upload-time = "2026-09-11T07:25:14.599Z" },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
+]
+
+[[package]]
+name = "wcwidth"
+version = "0.8.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/3d/7a/f98d4ada7c499565ab0c0fcef28a4e54fafa72b8228a6309803c80493c92/wcwidth-0.8.4.tar.gz", hash = "sha256:2dae09efa25253ae2874188e86d6861af3b1652aef4118cdf3f0bda288a957fb", size = 414609, upload-time = "2026-09-17T23:31:45.287Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/39/cc/9c0adc5861020de426ec71716b6c1bef8d90694b7d6d0a16202cdb1647f5/wcwidth-0.8.4-py3-none-any.whl", hash = "sha256:2097bb1d28a0ba8fe177c2eb607317f9b1627b03f33ebebfd3da670b337a65ba", size = 299474, upload-time = "2026-09-17T23:31:43.816Z" },
+]
+
+[[package]]
+name = "webrtcvad-wheels"
+version = "2.0.14"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/28/ba/3a8ce2cff3eee72a39ed190e5f9dac792da1526909c97a11589590b21739/webrtcvad_wheels-2.0.14.tar.gz", hash = "sha256:5f59c8e291c6ef102d9f39532982fbf26a52ce2de6328382e2654b0960fea397", size = 70607, upload-time = "2024-09-05T10:17:02.471Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/f6/a3/d17061911034aa38c10bd9143f21396b4e74448013c9049e94e445805c96/webrtcvad_wheels-2.0.14-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e17f5b273523ae9de28117078107e4a2cee1b521d99d5fd2a88622277d7fa06f", size = 31004, upload-time = "2024-09-05T10:14:55.783Z" },
+    { url = "https://files.pythonhosted.org/packages/f3/87/bff850cb6f0a875f32a7ff544416738cabe028a483a8e54056e9a70f9cd0/webrtcvad_wheels-2.0.14-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f1f6e6cb99e58f65d9a357fef0703263cf2eb5fa4d63333fff2aba73e2ad3947", size = 29497, upload-time = "2024-09-05T10:14:56.729Z" },
+    { url = "https://files.pythonhosted.org/packages/2e/ef/fe3e6214c9bbaa852c41fd3a11dc0c3465d621f0639322ffbd26bff835e8/webrtcvad_wheels-2.0.14-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4003139f180b8e4930b862d662dc574602e4547c8fb497bc48579b7cacc4110", size = 86295, upload-time = "2024-09-05T10:14:57.997Z" },
+    { url = "https://files.pythonhosted.org/packages/a5/95/4669dec6466c6c4a8720a9d66b7839d3d386be534add7a59d8cf205d8eb8/webrtcvad_wheels-2.0.14-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af2656e2e344abaad2fab8f4441a1ef850643bd7de15c2df54ca6c9fa92961f5", size = 95763, upload-time = "2024-09-05T10:14:59.378Z" },
+    { url = "https://files.pythonhosted.org/packages/53/ba/4693ee3610c363505d355265804d39aa46c4acfbc7f6db6bab4bd6236418/webrtcvad_wheels-2.0.14-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cbef958b145573fb425482dbbd74fc080540a510c3871e1cebd59afa83c87f", size = 83082, upload-time = "2024-09-05T10:15:00.543Z" },
+    { url = "https://files.pythonhosted.org/packages/c3/10/c6d8335b1063e962ff6cbefb06b602627eb1b4f30d890611638c6c575665/webrtcvad_wheels-2.0.14-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bce6be5aef9512dbbaccbf16df2793c9e1a3b538c8cebb05976cf4e44bd3381f", size = 80983, upload-time = "2024-09-05T10:15:01.381Z" },
+    { url = "https://files.pythonhosted.org/packages/60/2a/97c9e5681ac0d47430c5bd280aaa7be63431cff1c620c489771734491cbd/webrtcvad_wheels-2.0.14-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:32409315eaaec46ea58fb7e729193c66573d95124208efd03f332bc2fc4c6c7b", size = 86496, upload-time = "2024-09-05T10:15:02.242Z" },
+    { url = "https://files.pythonhosted.org/packages/9f/8b/56863c61df31cd33af62f1a795c6afca73acb3fb93eb377908a1da991f72/webrtcvad_wheels-2.0.14-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:aa8e901c1cdc4344a62a09745b2b3cdf34617801efc49d433fdbc238193efc9c", size = 82668, upload-time = "2024-09-05T10:15:03.473Z" },
+    { url = "https://files.pythonhosted.org/packages/68/ec/95c48ec2dce1bea0cffdfb03055d2e0f958e2ea2e5754c35bc144c882df4/webrtcvad_wheels-2.0.14-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e739720c6b5bb0a21629f3a43563d8afe1de1b40d0ca07a39bc70f0584e05cc8", size = 93642, upload-time = "2024-09-05T10:15:04.77Z" },
+    { url = "https://files.pythonhosted.org/packages/59/ca/ceb1e26f1dcddbe238ae0f769200073572ce61fe2f848476273253748004/webrtcvad_wheels-2.0.14-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:04e47b87bf673098f25e2c2e07e486b58a339a93231ee0021a86a92b3b759b89", size = 87014, upload-time = "2024-09-05T10:15:05.87Z" },
+    { url = "https://files.pythonhosted.org/packages/62/50/9677355cbb2a9a32c1be3e3fee8c393d5f7f370943914ed9a27c58538a1f/webrtcvad_wheels-2.0.14-cp311-cp311-win32.whl", hash = "sha256:4838e120cbd2e76bd47abc338c3121e8dea7653ae19650ce4f9c40b9bf84bc19", size = 17369, upload-time = "2024-09-05T10:15:06.707Z" },
+    { url = "https://files.pythonhosted.org/packages/80/bd/4309d83120812d04af99aa21a976a4bb8cc10e39f709232c0180bd4e0146/webrtcvad_wheels-2.0.14-cp311-cp311-win_amd64.whl", hash = "sha256:0d9ad6ef4ef7c89228469df52a4657886055a9f1ab036ede09400e2279ac5c70", size = 19901, upload-time = "2024-09-05T10:15:07.562Z" },
+    { url = "https://files.pythonhosted.org/packages/dd/1f/af4af5d30228d46bbc3ad85cd12aac670b930d94107d8c755c191e5454f3/webrtcvad_wheels-2.0.14-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:fc5d5a5bb13e5f25e095859d7a0018101a6563434781f27965f38ee75da45f9e", size = 31016, upload-time = "2024-09-05T10:15:08.799Z" },
+    { url = "https://files.pythonhosted.org/packages/df/7f/8ff8af528b0a8db20d28356dc5486ecf779a7d1bd9ff379d8b3d921a2ab3/webrtcvad_wheels-2.0.14-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:323aa89df721377f053923fe1ea5c14e748c85314a977e2fcc3c920c4f474d40", size = 29515, upload-time = "2024-09-05T10:15:09.829Z" },
+    { url = "https://files.pythonhosted.org/packages/24/16/8e21cfabd0cd9b9d01231e3b65716ab0cf1a6bd0b9115e2ed038996fe2c1/webrtcvad_wheels-2.0.14-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42310d3bb6cdb46a79a219d14fe97ae272b8af629d84321e5cfa0764556109a6", size = 86036, upload-time = "2024-09-05T10:15:11.122Z" },
+    { url = "https://files.pythonhosted.org/packages/8a/c3/51e1f87610b55823367f08253fba50659beda2807e2ec4cf08360aff6c25/webrtcvad_wheels-2.0.14-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fdc138ce3519e2f4ba2f74640bc6f72aff162fa0c9ae2941da8068f3d22ba6", size = 95471, upload-time = "2024-09-05T10:15:12.652Z" },
+    { url = "https://files.pythonhosted.org/packages/84/59/a6cd0eeae17640e43215843ea6176645b65f58dd6d15a38c77c253116e87/webrtcvad_wheels-2.0.14-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63fdad42c0ca6248b9a5c8ca34ff17a5c43bb63ffd8997a5902ad7237a05ca68", size = 82895, upload-time = "2024-09-05T10:15:13.738Z" },
+    { url = "https://files.pythonhosted.org/packages/7b/cd/fd784f552a32d1b44be9ffe8b8f243c8c9a9eb69a61ef92a8b6611d38349/webrtcvad_wheels-2.0.14-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7aa4cfbf0c816d2b9ee3100d3008f5acf8bd1a634d6058fa6c1604a1e2ba264", size = 80808, upload-time = "2024-09-05T10:15:15.166Z" },
+    { url = "https://files.pythonhosted.org/packages/7b/0c/28846038f5de2872b91a5b91925792d056477cd42a6d639b6a3ba84bd0e8/webrtcvad_wheels-2.0.14-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6056a2559d83836bf4b6af4993448202bfd080efd5e799d2153118e4a91d3fd1", size = 86266, upload-time = "2024-09-05T10:15:16.382Z" },
+    { url = "https://files.pythonhosted.org/packages/17/38/8b20fb52c14bc316089a7486b559412c785968b17f97b5486ad17e115b0e/webrtcvad_wheels-2.0.14-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:db9d5e6ae07cc82277eacac2b747afa7db53e9effab0259055116f5f77a28bf3", size = 82609, upload-time = "2024-09-05T10:15:17.707Z" },
+    { url = "https://files.pythonhosted.org/packages/3c/7c/3a8abe5cc3d2b65072b24c4122ceae29de0ac5d4d5df45ebf7f3f101ef74/webrtcvad_wheels-2.0.14-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:407c29a1d577dff8c2ef746bbd78151688f23ca763d97f3334d6e310ffa68aaa", size = 93359, upload-time = "2024-09-05T10:15:19.021Z" },
+    { url = "https://files.pythonhosted.org/packages/7a/e6/14069e350e5cbd78f7c16add5613f03378d2ee36c43d5dc96d20ce9bc8e6/webrtcvad_wheels-2.0.14-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe2ced612732a41aefa14ea2ea52f84de5afd43665ebed092e1d6df93641e239", size = 86940, upload-time = "2024-09-05T10:15:20.301Z" },
+    { url = "https://files.pythonhosted.org/packages/ec/7c/ac5a40ca80a09e978968b7065528ce340295115d1af2b1e0a98aa0ac0244/webrtcvad_wheels-2.0.14-cp312-cp312-win32.whl", hash = "sha256:68e2041d1a30dc619a0e7ff5adaa948a6b862fa6e6d4d85337f235240707eab5", size = 17364, upload-time = "2024-09-05T10:15:21.162Z" },
+    { url = "https://files.pythonhosted.org/packages/24/1d/9a8f4c842ca880253821acf165080077b245577719e5e03b13e3c45853c5/webrtcvad_wheels-2.0.14-cp312-cp312-win_amd64.whl", hash = "sha256:4010b95b31b9b360fd1bf47a8344b06b946ec866cf3d14fb39fc692307ddf312", size = 19901, upload-time = "2024-09-05T10:15:21.96Z" },
+    { url = "https://files.pythonhosted.org/packages/51/ab/08750672514f4d9d0304d8648ebdf7827c38e266263953629b22a370c68f/webrtcvad_wheels-2.0.14-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:984f412292bd6edb3487911a5b2e36d1690a6afb8fc3fab18286b52fdf20eea0", size = 32768, upload-time = "2024-09-05T10:15:22.797Z" },
+    { url = "https://files.pythonhosted.org/packages/ba/e0/ab4624a30c59abeaa6824cda455fd7842b7feafa94c7e52f35933926be88/webrtcvad_wheels-2.0.14-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:578151f6d1d58a3735fc4c4b7d47db3d42503a49587f84d9c42c2cd8aee93867", size = 29519, upload-time = "2024-09-05T10:15:23.867Z" },
+    { url = "https://files.pythonhosted.org/packages/0e/cb/b1f1890e863e6adf0f6d3d9aabe1951169b7f514eae2fd8bd266752e923b/webrtcvad_wheels-2.0.14-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:792ce66f32b6d3ffa7569ef467b4ec1a5a45eb95cc466d204c04c120f4169015", size = 85987, upload-time = "2024-09-05T10:15:26.021Z" },
+    { url = "https://files.pythonhosted.org/packages/cc/3d/78c1f47214bf75682aba75435a9762451f1d1392d810fae1b3a5ece1aecb/webrtcvad_wheels-2.0.14-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de5d6d71cbe2b92ec8411abd9c40d86e32f7d65f92a41029c0387d59c642810b", size = 95426, upload-time = "2024-09-05T10:15:27.231Z" },
+    { url = "https://files.pythonhosted.org/packages/07/07/4b9eff8e3eb64e7017e6b28d7d3881290f19ece015ad182824f52ae088d0/webrtcvad_wheels-2.0.14-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c67055236f1ffcf160825bb87dbe2c1d3846ad51b54602906b1c7ea37a0b4ac", size = 82864, upload-time = "2024-09-05T10:15:29.73Z" },
+    { url = "https://files.pythonhosted.org/packages/9e/32/067431c5313722860f37602d6c37a53f5f9250a0c46c70e918a8afce0293/webrtcvad_wheels-2.0.14-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2f2ae19589d89e1e67ad08f62fa11d5edce1a05a9a0e61d74ac3af6762da4caf", size = 80776, upload-time = "2024-09-05T10:15:31.092Z" },
+    { url = "https://files.pythonhosted.org/packages/06/73/d297e6ea1493005e1b05c9ce56638f097bfd665a71045dad57000224a4fd/webrtcvad_wheels-2.0.14-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7de1df623d148052a61bd151545344dfafcf85dfb7075ea0e855009c080eb0ec", size = 86313, upload-time = "2024-09-05T10:15:32.014Z" },
+    { url = "https://files.pythonhosted.org/packages/23/08/26f15a04aefeade474148ebf32ecc9aa68bb001fcea24dc8a3c299677e3d/webrtcvad_wheels-2.0.14-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bcdffafce093f72ccea028698163b33c85267481900f8389fc2ecc1d6d1b57ba", size = 82633, upload-time = "2024-09-05T10:15:32.876Z" },
+    { url = "https://files.pythonhosted.org/packages/ac/f9/02a13c7dd6113ecc3f2e02d4a2ef54586e55a0bb4ea3772b54df9e08eb49/webrtcvad_wheels-2.0.14-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1a7a3cb39b4d33cf4895fceea9a12d4e30a44b98a9512e661ffb0a520b3f6bfb", size = 93403, upload-time = "2024-09-05T10:15:34.044Z" },
+    { url = "https://files.pythonhosted.org/packages/a7/6e/0cd49b425ee9ccba2acd8451cbd96e6318081abcde6d9d6caa2083e53fab/webrtcvad_wheels-2.0.14-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:34b315326dd6c8529221492ebf28fc649d96b1a49ce49bf649eca60aabc70a2d", size = 86979, upload-time = "2024-09-05T10:15:35.045Z" },
+    { url = "https://files.pythonhosted.org/packages/77/36/52e8998f8421c746defa8f8b509cab4aac984ef39a2cd5818f664901d245/webrtcvad_wheels-2.0.14-cp313-cp313-win32.whl", hash = "sha256:ff2a7eb4fe2189a7766726d6122b319df3e727c4cfed86409e2802fed1b459d3", size = 17368, upload-time = "2024-09-05T10:15:35.973Z" },
+    { url = "https://files.pythonhosted.org/packages/f9/7c/73b9dd022c835e19180aad8aafd2f33863c02cbbafd5bc599dd54f6c1111/webrtcvad_wheels-2.0.14-cp313-cp313-win_amd64.whl", hash = "sha256:561f87975930833a5b77135fb6f15e6df430bfc000c327dc517a175aef15a707", size = 19901, upload-time = "2024-09-05T10:15:36.877Z" },
+]