From 0dbdbb62d1a1fa4f84e1269ec290503826758cba Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 12:27:13 +0000 Subject: [PATCH 01/38] v0.3.0: modular MLX-first package (Parakeet + Qwen3, config, SwiftBar, benchmarks) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Evolve the single-file proof-of-concept (preserved at the nano tag) into a small modular package: - Pluggable STT backends as extras: parakeet (default) and whisper. MLX stays an explicit extra so the install is Mac-first and you pick one engine. - Default backend is Parakeet (parakeet-tdt-0.6b-v3) — far faster than Whisper on Apple Silicon; Whisper remains the fallback. - Cleanup upgraded to qwen3:4b-instruct-2507 (non-thinking); strip stray . - Config at ~/.v2t/config.toml (honors V2T_HOME / XDG_CONFIG_HOME) + JSONL history. - SwiftBar plugin: menu-bar toggle, status, open config/history. - v2t bench: per-machine markdown grid (STT RTF + cleanup TTFT/total), self-contained audio via macOS say. - New commands: status, stop, config, bench. Cleanup streams the Ollama HTTP API. https://claude.ai/code/session_01XTW6U37PNqStHyZ9BZEM6r --- CHANGELOG.md | 14 ++ README.md | 168 +++++++++------ benchmarks/README.md | 56 +++++ benchmarks/results/.gitkeep | 0 justfile | 6 +- pyproject.toml | 19 +- swiftbar/v2t.5s.sh | 60 ++++++ uv.lock | 420 +++++++++++++++++++++++++++++++++++- v2t/__init__.py | 3 + v2t/__main__.py | 3 + v2t/app.py | 240 +++++++++++++++++++++ v2t/backends.py | 127 +++++++++++ v2t/bench.py | 209 ++++++++++++++++++ v2t/cli.py | 130 +++++++++++ v2t/config.py | 159 ++++++++++++++ voice2text.py | 291 ------------------------- 16 files changed, 1540 insertions(+), 365 deletions(-) create mode 100644 benchmarks/README.md create mode 100644 benchmarks/results/.gitkeep create mode 100755 swiftbar/v2t.5s.sh create mode 100644 v2t/__init__.py create mode 100644 v2t/__main__.py create mode 100644 v2t/app.py create mode 100644 v2t/backends.py create mode 100644 v2t/bench.py create mode 100644 v2t/cli.py create mode 100644 v2t/config.py delete mode 100644 voice2text.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 889c9bf..1f115dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 0.3.0 + +The single-file proof-of-concept is preserved at the [`nano`](https://github.com/lucharo/voice2text/releases/tag/nano) tag. +This release turns v2t into a small, modular, MLX-first package. + +- **Parakeet is the default backend** (`mlx-community/parakeet-tdt-0.6b-v3`) — ~10× faster than Whisper on Apple Silicon, multilingual. Whisper stays available as the `whisper` backend. +- **Pluggable STT backends** as install extras: `voice2text[parakeet]` (default), `voice2text[whisper]`, `voice2text[all]`. +- **Cleanup upgraded to `qwen3:4b-instruct-2507`** (latest small non-thinking instruct model). Stray `` blocks are stripped defensively. +- **Config file** at `~/.v2t/config.toml` (honors `$V2T_HOME` / `$XDG_CONFIG_HOME`), with `v2t config [--init]`. +- **Transcription history** — every result + metadata appended to `~/.v2t/history/transcriptions.jsonl`. +- **SwiftBar plugin** (`swiftbar/v2t.5s.sh`) — menu-bar toggle, status, open config/history. +- **Benchmark harness** — `v2t bench` writes a per-machine markdown grid (STT RTF + cleanup TTFT/total). Self-contained STT inputs via macOS `say`. +- **New commands**: `v2t status`, `v2t stop`, `v2t config`, `v2t bench`. Cleanup now streams the Ollama HTTP API (so TTFT is measurable), falling back to `ollama run`. + ## 0.2.0 - Warm up both Whisper and Ollama models at startup so the first transcription is fast diff --git a/README.md b/README.md index 0027961..265fc88 100644 --- a/README.md +++ b/README.md @@ -3,116 +3,156 @@ [![PyPI](https://img.shields.io/pypi/v/voice2text)](https://pypi.org/project/voice2text/) [![Downloads](https://static.pepy.tech/badge/voice2text/month)](https://pepy.tech/project/voice2text) [![Total Downloads](https://static.pepy.tech/badge/voice2text)](https://pepy.tech/project/voice2text) -[![macOS](https://img.shields.io/badge/macOS-only-blue?logo=apple)](https://github.com/lucharo/voice2text) +[![macOS](https://img.shields.io/badge/macOS-Apple%20Silicon-blue?logo=apple)](https://github.com/lucharo/voice2text) [![Works on my machine](https://img.shields.io/badge/works-on%20my%20machine-brightgreen)](https://github.com/lucharo/voice2text) -Local voice-to-text with Whisper + LLM cleanup. Push-to-talk (Right ⌘), pastes at cursor. +Local, MLX-first voice-to-text. Hold **Right ⌘**, talk, release — it transcribes with +[Parakeet](https://huggingface.co/mlx-community/parakeet-tdt-0.6b-v3), cleans the text up with a +small local LLM, and pastes at your cursor. No cloud, no network, no account. -Voice-to-text tools like [Wispr Flow](https://wisprflow.ai/), [MacWhisper](https://goodsnooze.gumroad.com/l/macwhisper), and [VoiceInk](https://www.voiceink.app/) are becoming increasingly popular. It's a testament to our times that in 2025, ~270 lines of Python with local Whisper and a small `ollama` language model (Qwen 2.5-3B) can deliver a comparable experience on consumer hardware. Such tooling would have been unimaginable 3 years ago. This project is a proof of concept to demonstrate just that. +Voice-to-text tools like [Wispr Flow](https://wisprflow.ai/), [MacWhisper](https://goodsnooze.gumroad.com/l/macwhisper), +and [VoiceInk](https://www.voiceink.app/) are great until the network gets in the way — Wispr Flow +[isn't compatible with most VPNs](https://docs.wisprflow.ai/troubleshooting), so behind a corporate +SSL-intercepting gateway (Zscaler and friends) it stalls or fails. Going local makes that whole class +of problem disappear: zero network dependency means the VPN is irrelevant. Speech models are good +enough now that the basics fit in a small Python package on consumer hardware. -> **Note:** Before anyone suggests splitting this into modules and submodules — this is an intentional design choice to demonstrate how this whole functionality fits in less than 300 lines of python code. +> **Heritage:** this started as a single `voice2text.py` under 300 lines — that proof-of-concept is +> preserved forever at the [`nano`](https://github.com/lucharo/voice2text/releases/tag/nano) tag. +> From `0.3.0` it's a small, modular package: pluggable MLX backends, one config file, a benchmark +> harness, and a SwiftBar menu-bar toggle. -> **Note 2:** This is macOS-only by design. We use: -> - **[mlx-whisper](https://github.com/ml-explore/mlx-examples/tree/main/whisper)** — optimized for Apple Silicon -> - **osascript** — for simulating Cmd+V paste via System Events -> - **pbcopy/pbpaste** — macOS clipboard -> - **[nowplaying-cli](https://github.com/kirtan-shah/nowplaying-cli)** — macOS media control -> - **System Preferences** URLs for permissions -> -> You're welcome to fork this and make it work on Linux or Windows! +## What you get -## Prerequisites - -> Skip this if using **`pixi`** — it handles ollama automatically. - -```bash -brew install ollama -ollama pull qwen2.5:3b -``` +- **Push-to-talk** — hold Right ⌘ (configurable), release to transcribe + paste. +- **Parakeet (MLX)** transcription by default — ~10× faster than Whisper on Apple Silicon, English + 24 European languages. Whisper stays available as a fallback for rare languages/accents. +- **Local LLM cleanup** via Ollama (`qwen3:4b-instruct-2507`) — fixes punctuation and removes fillers. Strict or casual. +- **Pastes at cursor**, restoring your previous clipboard. +- **One config file** at `~/.v2t/config.toml`, plus a JSONL **history** of every transcription. +- **SwiftBar plugin** — menu-bar toggle, status, and quick links to your config/history. +- **Benchmark harness** — `v2t bench` writes a per-machine markdown grid of model speeds. ## Install -### uvx (quick try) - -The fastest way to try it out. Note: startup is slower because `uvx` creates a fresh virtual environment each time. +Requires **macOS on Apple Silicon** (MLX) and **[Ollama](https://ollama.com)** for cleanup. ```bash -uvx --from voice2text v2t -``` - -Or from GitHub: - -```bash -uvx --from git+https://github.com/lucharo/voice2text v2t +brew install ollama +ollama pull qwen3:4b-instruct-2507 ``` -### uv tool install (recommended for daily use) - -Installs `v2t` as a persistent command — no virtual environment setup on each run, so startup is fast. +Then install v2t with the backend you want (quote the brackets — zsh treats them as globs): ```bash -uv tool install voice2text +uv tool install 'voice2text[parakeet]' # recommended — Parakeet (default backend) +uv tool install 'voice2text[whisper]' # Whisper instead (no Parakeet) +uv tool install 'voice2text[all]' # both, switch via config v2t ``` -### pip - -```bash -pip install voice2text -v2t -``` +> **Note on extras:** MLX is an explicit extra so the install stays Mac-first and you pick exactly +> one engine. A bare `voice2text` (no extra) installs no backend and tells you to add `[parakeet]`. +> ([PEP 771](https://peps.python.org/pep-0771/) "default extras" would let a bare install imply +> Parakeet automatically, but it isn't supported by uv/hatchling yet.) -### Development install +
+Other install methods (uvx, pip, dev, pixi) ```bash -git clone https://github.com/lucharo/voice2text.git -cd voice2text -uv sync -uv run v2t -``` +# quick try (fresh venv each run — slower startup) +uvx --from 'voice2text[parakeet]' v2t -### Pixi +# pip +pip install 'voice2text[parakeet]' && v2t -Pixi handles the ollama dependency automatically: +# from source +git clone https://github.com/lucharo/voice2text.git && cd voice2text +uv sync --extra parakeet && uv run v2t -```bash -git clone https://github.com/lucharo/voice2text.git -cd voice2text -pixi run ollama pull qwen2.5:3b +# pixi (handles ollama; installs the parakeet extra) +pixi run ollama pull qwen3:4b-instruct-2507 pixi run v2t ``` - -> **Note:** We don't publish to conda-forge/pixi channels yet, but may in the future. +
## Usage ```bash -v2t # strict mode (restructures sentences) -v2t --casual # light cleanup (punctuation only) -v2t --pause-music # pause media while recording (macOS only, requires nowplaying-cli via brew) +v2t # run push-to-talk (strict cleanup, parakeet) +v2t --casual # light cleanup (punctuation + fillers only) +v2t --no-cleanup # paste raw transcription, skip the LLM +v2t --backend whisper # use the whisper backend for this run +v2t --pause-music # pause media while recording (needs nowplaying-cli) + +v2t status # running / idle (used by the SwiftBar plugin) +v2t stop # stop a running v2t +v2t config # show resolved config + paths (--init writes a template) +v2t bench # benchmark STT + cleanup models on this machine ``` Hold **Right Command** to record, release to transcribe and paste. -### Strict vs Casual Mode +### Strict vs Casual | Raw transcription | Strict | Casual | |-------------------|--------|--------| | "Hey um I'll see you tomorrow at 9 actually no make it 10" | "Hey, I'll see you tomorrow at 10." | "Hey, I'll see you tomorrow at 9, actually no, make it 10." | | "So basically I was thinking we could um you know maybe try the other approach" | "I was thinking we could try the other approach." | "So basically, I was thinking we could maybe try the other approach." | +**Strict** (default) removes fillers, restructures for clarity. **Casual** only adds punctuation and removes "um/uh", keeping your phrasing. -**Strict** (default): Removes filler words, restructures for clarity, condenses. +## Config — `~/.v2t/` + +Everything lives in one directory (override with `$V2T_HOME`, or `$XDG_CONFIG_HOME/v2t`): + +``` +~/.v2t/ + config.toml # all settings (v2t config --init to create) + history/transcriptions.jsonl # every transcription + metadata (toggle in config) + run/ # pid + status for the SwiftBar plugin +``` -**Casual**: Only adds punctuation and removes "um/uh", keeps your phrasing. +`config.toml` (every key optional — these are the defaults): -### `--pause-music` (macOS only) +```toml +[transcription] +backend = "parakeet" # parakeet (MLX) | whisper +model = "" # blank = backend default -Pauses any playing media while recording and resumes after. Requires: +[cleanup] +enabled = true +model = "qwen3:4b-instruct-2507" # any ollama model; use a non-thinking -instruct one +mode = "strict" # strict | casual + +[hotkey] +key = "cmd_r" # cmd_r | cmd_l | alt_r | alt_l | ctrl_r | ctrl_l + +[behavior] +pause_music = false +save_history = true +``` + +## SwiftBar menu-bar toggle ```bash -brew install nowplaying-cli +brew install swiftbar +cp swiftbar/v2t.5s.sh "$HOME/Library/Application Support/SwiftBar/Plugins/" # your plugins folder ``` -Not available via pixi/conda-forge for now, maybe will publish later! +The menu shows running/idle, the current model · mode, a Start/Stop toggle, and links to open +your config and transcription history. **Start from the menu** needs SwiftBar to have Accessibility + +Input Monitoring permissions; otherwise start `v2t` in a terminal and use the menu for status/stop. + +## Models & benchmarks + +`v2t bench` writes `benchmarks/results/-.md` — two tables (speech-to-text RTF, and cleanup +TTFT/total), one column per model. Run it on each machine to build a grid. See +[`benchmarks/`](benchmarks/) for the method and defaults. + +| | default | why | +|---|---|---| +| transcription | `parakeet-tdt-0.6b-v3` | fastest on Apple Silicon, multilingual | +| cleanup | `qwen3:4b-instruct-2507` | latest small instruct, non-thinking | +> This is **macOS / Apple Silicon-only** by design (MLX, `osascript` paste, `pbcopy`/`pbpaste`, +> `nowplaying-cli`, System Settings permission URLs). Fork it for Linux/Windows if you like. diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 0000000..a664e44 --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,56 @@ +# Benchmarks + +Two tables, models-as-columns, **median of N runs per cell**: + +- **Speech-to-text** — transcription time and real-time factor (RTF = audio length ÷ processing time; higher is faster). Inputs are generated on the fly with macOS `say`, so the benchmark is self-contained. +- **Text cleanup** — time-to-first-token and total time for the Ollama cleanup model. + +Absolute numbers reflect the machine they ran on. **Compare models within a file, not across machines** — that's the point of one file per host. + +## Run it + +```bash +v2t bench # both tables, default models +v2t bench --cleanup # cleanup only (runs anywhere Ollama runs — no Apple Silicon needed) +v2t bench --stt # STT only (needs Apple Silicon / MLX) +v2t bench --repeat 5 # more runs per cell +``` + +Override the model lists: + +```bash +v2t bench --stt-models \ + parakeet:mlx-community/parakeet-tdt-0.6b-v3 \ + parakeet:mlx-community/parakeet-tdt-0.6b-v2 \ + whisper:mlx-community/whisper-large-v3-turbo + +v2t bench --cleanup-models qwen3:4b-instruct-2507 qwen3:1.7b qwen2.5:3b +``` + +Each run writes `results/-.md`. Commit it. Run on each Mac (and the +Linux box, for the cleanup table) to build the grid. + +## Default models + +| | model | notes | +|---|---|---| +| STT (default) | `mlx-community/parakeet-tdt-0.6b-v3` | multilingual (EN/ES/FR/DE…), fastest on Apple Silicon | +| STT (alt) | `mlx-community/parakeet-tdt-0.6b-v2` | English-only, slightly higher EN accuracy | +| STT (fallback) | `mlx-community/whisper-large-v3-turbo` | the old default; best for rare languages/accents | +| cleanup (default) | `qwen3:4b-instruct-2507` | latest small instruct, **non-thinking** | +| cleanup (alt) | `qwen3:1.7b` | smaller/faster | +| cleanup (old) | `qwen2.5:3b` | the previous default | + +> **Why the `-instruct-2507` Qwen3 and not plain `qwen3`?** The plain tags default +> to hybrid *thinking* mode and emit `` blocks, which add latency +> and pollute the output. The `-instruct` variants don't think. (v2t also strips +> any stray `` blocks defensively.) + +## Method + +- Models loaded once, then each sample transcribed/cleaned `--repeat` times; the median is reported. +- STT inputs: short / medium / long clips synthesized with `say` and converted to 16 kHz mono with `afconvert` — no audio files to ship, fully reproducible. +- Cleanup inputs: three filler-laden raw transcriptions (see `v2t/bench.py`). +- TTFT comes from streaming the Ollama HTTP API and timing the first token. + +See `results/` for collected runs. diff --git a/benchmarks/results/.gitkeep b/benchmarks/results/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/justfile b/justfile index 3b95f98..9530fa2 100644 --- a/justfile +++ b/justfile @@ -1,6 +1,10 @@ # Lint with ruff lint: - uv run ruff check --fix voice2text.py + uv run ruff check --fix v2t/ + +# Run module self-checks (no models needed) +check: + python3 -m v2t.config && python3 -m v2t.backends && python3 -m v2t.bench # Build the package build: diff --git a/pyproject.toml b/pyproject.toml index 3cd5a13..301e9a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,11 @@ [project] name = "voice2text" -version = "0.2.0" -description = "Local voice-to-text with Whisper + LLM cleanup" +version = "0.3.0" +description = "Local, MLX-first voice-to-text with LLM cleanup" readme = "README.md" license = "GPL-2.0-only" requires-python = ">=3.11" dependencies = [ - "mlx-whisper", "sounddevice", "numpy", "scipy", @@ -14,8 +13,15 @@ dependencies = [ "loguru", ] +# MLX backends are extras (Apple Silicon only). Parakeet is the recommended +# default; install it explicitly. Picking `whisper` keeps Parakeet out. +[project.optional-dependencies] +parakeet = ["parakeet-mlx"] +whisper = ["mlx-whisper"] +all = ["parakeet-mlx", "mlx-whisper"] + [project.scripts] -v2t = "voice2text:main" +v2t = "v2t.cli:main" [dependency-groups] dev = ["hatch", "ruff"] @@ -24,6 +30,9 @@ dev = ["hatch", "ruff"] requires = ["hatchling"] build-backend = "hatchling.build" +[tool.hatch.build.targets.wheel] +packages = ["v2t"] + [tool.pixi.workspace] channels = ["conda-forge"] platforms = ["osx-arm64"] @@ -34,7 +43,7 @@ ollama = "*" # nowplaying-cli not on conda-forge, install via: brew install nowplaying-cli [tool.pixi.pypi-dependencies] -voice2text = { path = ".", editable = true } +voice2text = { path = ".", editable = true, extras = ["parakeet"] } [tool.pixi.tasks] v2t = "v2t" diff --git a/swiftbar/v2t.5s.sh b/swiftbar/v2t.5s.sh new file mode 100755 index 0000000..fadc66a --- /dev/null +++ b/swiftbar/v2t.5s.sh @@ -0,0 +1,60 @@ +#!/bin/bash +# +# v2t +# 0.3.0 +# lucharo +# Toggle and monitor voice2text from the menu bar. +# v2t +# true +# true +# true +# +# Install: brew install swiftbar, then drop this file in your SwiftBar plugins +# folder (keep the `.5s.sh` suffix — it sets the 5-second refresh). +# +# "Start v2t" from the menu needs SwiftBar to have Accessibility + Input +# Monitoring permissions (System Settings > Privacy). Without them, start v2t +# from a terminal and use the menu just for status / stop / opening folders. + +export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" +V2T_HOME="${V2T_HOME:-$HOME/.v2t}" + +V2T_BIN="$(command -v v2t || true)" +for c in "$HOME/.local/bin/v2t" /opt/homebrew/bin/v2t /usr/local/bin/v2t; do + [ -z "$V2T_BIN" ] && [ -x "$c" ] && V2T_BIN="$c" +done + +# SwiftBar re-invokes this script with a param when a menu item is clicked. +case "$1" in + start) mkdir -p "$V2T_HOME/run"; nohup "$V2T_BIN" >>"$V2T_HOME/run/v2t.log" 2>&1 & exit 0 ;; + stop) "$V2T_BIN" stop >/dev/null 2>&1; exit 0 ;; +esac + +if [ -z "$V2T_BIN" ]; then + echo "🎙️ ⚠️" + echo "---" + echo "v2t not found on PATH" + echo "Install | href=https://github.com/lucharo/voice2text" + exit 0 +fi + +STATUS="$("$V2T_BIN" status 2>/dev/null)" +STATE="$(printf '%s' "$STATUS" | cut -f1)" + +if [ "$STATE" = "running" ]; then + MODEL="$(printf '%s' "$STATUS" | cut -f3)" + MODE="$(printf '%s' "$STATUS" | cut -f4)" + echo "🎙️ | color=#34c759" + echo "---" + echo "● Running ($MODEL · $MODE) | color=#34c759" + echo "Stop v2t | bash=\"$0\" param0=stop terminal=false refresh=true" +else + echo "🎙️ | color=#8e8e93" + echo "---" + echo "○ Idle | color=#8e8e93" + echo "Start v2t | bash=\"$0\" param0=start terminal=false refresh=true" +fi +echo "---" +echo "Open config (~/.v2t) | bash=/usr/bin/open param0=\"$V2T_HOME\" terminal=false" +echo "Open transcription history | bash=/usr/bin/open param0=\"$V2T_HOME/history\" terminal=false" +echo "Refresh | refresh=true" diff --git a/uv.lock b/uv.lock index 2938aed..dda2cdd 100644 --- a/uv.lock +++ b/uv.lock @@ -2,10 +2,20 @@ version = 1 revision = 3 requires-python = ">=3.11" resolution-markers = [ - "python_full_version >= '3.12'", + "python_full_version >= '3.13'", + "python_full_version == '3.12.*'", "python_full_version < '3.12'", ] +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + [[package]] name = "anyio" version = "4.12.0" @@ -19,6 +29,75 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" }, ] +[[package]] +name = "audioop-lts" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/53/946db57842a50b2da2e0c1e34bd37f36f5aadba1a929a3971c5d7841dbca/audioop_lts-0.2.2.tar.gz", hash = "sha256:64d0c62d88e67b98a1a5e71987b7aa7b5bcffc7dcee65b635823dbdd0a8dbbd0", size = 30686, upload-time = "2025-08-05T16:43:17.409Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/d4/94d277ca941de5a507b07f0b592f199c22454eeaec8f008a286b3fbbacd6/audioop_lts-0.2.2-cp313-abi3-macosx_10_13_universal2.whl", hash = "sha256:fd3d4602dc64914d462924a08c1a9816435a2155d74f325853c1f1ac3b2d9800", size = 46523, upload-time = "2025-08-05T16:42:20.836Z" }, + { url = "https://files.pythonhosted.org/packages/f8/5a/656d1c2da4b555920ce4177167bfeb8623d98765594af59702c8873f60ec/audioop_lts-0.2.2-cp313-abi3-macosx_10_13_x86_64.whl", hash = "sha256:550c114a8df0aafe9a05442a1162dfc8fec37e9af1d625ae6060fed6e756f303", size = 27455, upload-time = "2025-08-05T16:42:22.283Z" }, + { url = "https://files.pythonhosted.org/packages/1b/83/ea581e364ce7b0d41456fb79d6ee0ad482beda61faf0cab20cbd4c63a541/audioop_lts-0.2.2-cp313-abi3-macosx_11_0_arm64.whl", hash = "sha256:9a13dc409f2564de15dd68be65b462ba0dde01b19663720c68c1140c782d1d75", size = 26997, upload-time = "2025-08-05T16:42:23.849Z" }, + { url = "https://files.pythonhosted.org/packages/b8/3b/e8964210b5e216e5041593b7d33e97ee65967f17c282e8510d19c666dab4/audioop_lts-0.2.2-cp313-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:51c916108c56aa6e426ce611946f901badac950ee2ddaf302b7ed35d9958970d", size = 85844, upload-time = "2025-08-05T16:42:25.208Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2e/0a1c52faf10d51def20531a59ce4c706cb7952323b11709e10de324d6493/audioop_lts-0.2.2-cp313-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47eba38322370347b1c47024defbd36374a211e8dd5b0dcbce7b34fdb6f8847b", size = 85056, upload-time = "2025-08-05T16:42:26.559Z" }, + { url = "https://files.pythonhosted.org/packages/75/e8/cd95eef479656cb75ab05dfece8c1f8c395d17a7c651d88f8e6e291a63ab/audioop_lts-0.2.2-cp313-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba7c3a7e5f23e215cb271516197030c32aef2e754252c4c70a50aaff7031a2c8", size = 93892, upload-time = "2025-08-05T16:42:27.902Z" }, + { url = "https://files.pythonhosted.org/packages/5c/1e/a0c42570b74f83efa5cca34905b3eef03f7ab09fe5637015df538a7f3345/audioop_lts-0.2.2-cp313-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def246fe9e180626731b26e89816e79aae2276f825420a07b4a647abaa84becc", size = 96660, upload-time = "2025-08-05T16:42:28.9Z" }, + { url = "https://files.pythonhosted.org/packages/50/d5/8a0ae607ca07dbb34027bac8db805498ee7bfecc05fd2c148cc1ed7646e7/audioop_lts-0.2.2-cp313-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e160bf9df356d841bb6c180eeeea1834085464626dc1b68fa4e1d59070affdc3", size = 79143, upload-time = "2025-08-05T16:42:29.929Z" }, + { url = "https://files.pythonhosted.org/packages/12/17/0d28c46179e7910bfb0bb62760ccb33edb5de973052cb2230b662c14ca2e/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4b4cd51a57b698b2d06cb9993b7ac8dfe89a3b2878e96bc7948e9f19ff51dba6", size = 84313, upload-time = "2025-08-05T16:42:30.949Z" }, + { url = "https://files.pythonhosted.org/packages/84/ba/bd5d3806641564f2024e97ca98ea8f8811d4e01d9b9f9831474bc9e14f9e/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4a53aa7c16a60a6857e6b0b165261436396ef7293f8b5c9c828a3a203147ed4a", size = 93044, upload-time = "2025-08-05T16:42:31.959Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5e/435ce8d5642f1f7679540d1e73c1c42d933331c0976eb397d1717d7f01a3/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:3fc38008969796f0f689f1453722a0f463da1b8a6fbee11987830bfbb664f623", size = 78766, upload-time = "2025-08-05T16:42:33.302Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3b/b909e76b606cbfd53875693ec8c156e93e15a1366a012f0b7e4fb52d3c34/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_s390x.whl", hash = "sha256:15ab25dd3e620790f40e9ead897f91e79c0d3ce65fe193c8ed6c26cffdd24be7", size = 87640, upload-time = "2025-08-05T16:42:34.854Z" }, + { url = "https://files.pythonhosted.org/packages/30/e7/8f1603b4572d79b775f2140d7952f200f5e6c62904585d08a01f0a70393a/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:03f061a1915538fd96272bac9551841859dbb2e3bf73ebe4a23ef043766f5449", size = 86052, upload-time = "2025-08-05T16:42:35.839Z" }, + { url = "https://files.pythonhosted.org/packages/b5/96/c37846df657ccdda62ba1ae2b6534fa90e2e1b1742ca8dcf8ebd38c53801/audioop_lts-0.2.2-cp313-abi3-win32.whl", hash = "sha256:3bcddaaf6cc5935a300a8387c99f7a7fbbe212a11568ec6cf6e4bc458c048636", size = 26185, upload-time = "2025-08-05T16:42:37.04Z" }, + { url = "https://files.pythonhosted.org/packages/34/a5/9d78fdb5b844a83da8a71226c7bdae7cc638861085fff7a1d707cb4823fa/audioop_lts-0.2.2-cp313-abi3-win_amd64.whl", hash = "sha256:a2c2a947fae7d1062ef08c4e369e0ba2086049a5e598fda41122535557012e9e", size = 30503, upload-time = "2025-08-05T16:42:38.427Z" }, + { url = "https://files.pythonhosted.org/packages/34/25/20d8fde083123e90c61b51afb547bb0ea7e77bab50d98c0ab243d02a0e43/audioop_lts-0.2.2-cp313-abi3-win_arm64.whl", hash = "sha256:5f93a5db13927a37d2d09637ccca4b2b6b48c19cd9eda7b17a2e9f77edee6a6f", size = 24173, upload-time = "2025-08-05T16:42:39.704Z" }, + { url = "https://files.pythonhosted.org/packages/58/a7/0a764f77b5c4ac58dc13c01a580f5d32ae8c74c92020b961556a43e26d02/audioop_lts-0.2.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:73f80bf4cd5d2ca7814da30a120de1f9408ee0619cc75da87d0641273d202a09", size = 47096, upload-time = "2025-08-05T16:42:40.684Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ed/ebebedde1a18848b085ad0fa54b66ceb95f1f94a3fc04f1cd1b5ccb0ed42/audioop_lts-0.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:106753a83a25ee4d6f473f2be6b0966fc1c9af7e0017192f5531a3e7463dce58", size = 27748, upload-time = "2025-08-05T16:42:41.992Z" }, + { url = "https://files.pythonhosted.org/packages/cb/6e/11ca8c21af79f15dbb1c7f8017952ee8c810c438ce4e2b25638dfef2b02c/audioop_lts-0.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fbdd522624141e40948ab3e8cdae6e04c748d78710e9f0f8d4dae2750831de19", size = 27329, upload-time = "2025-08-05T16:42:42.987Z" }, + { url = "https://files.pythonhosted.org/packages/84/52/0022f93d56d85eec5da6b9da6a958a1ef09e80c39f2cc0a590c6af81dcbb/audioop_lts-0.2.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:143fad0311e8209ece30a8dbddab3b65ab419cbe8c0dde6e8828da25999be911", size = 92407, upload-time = "2025-08-05T16:42:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/87/1d/48a889855e67be8718adbc7a01f3c01d5743c325453a5e81cf3717664aad/audioop_lts-0.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfbbc74ec68a0fd08cfec1f4b5e8cca3d3cd7de5501b01c4b5d209995033cde9", size = 91811, upload-time = "2025-08-05T16:42:45.325Z" }, + { url = "https://files.pythonhosted.org/packages/98/a6/94b7213190e8077547ffae75e13ed05edc488653c85aa5c41472c297d295/audioop_lts-0.2.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cfcac6aa6f42397471e4943e0feb2244549db5c5d01efcd02725b96af417f3fe", size = 100470, upload-time = "2025-08-05T16:42:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/e9/e9/78450d7cb921ede0cfc33426d3a8023a3bda755883c95c868ee36db8d48d/audioop_lts-0.2.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:752d76472d9804ac60f0078c79cdae8b956f293177acd2316cd1e15149aee132", size = 103878, upload-time = "2025-08-05T16:42:47.576Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e2/cd5439aad4f3e34ae1ee852025dc6aa8f67a82b97641e390bf7bd9891d3e/audioop_lts-0.2.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:83c381767e2cc10e93e40281a04852facc4cd9334550e0f392f72d1c0a9c5753", size = 84867, upload-time = "2025-08-05T16:42:49.003Z" }, + { url = "https://files.pythonhosted.org/packages/68/4b/9d853e9076c43ebba0d411e8d2aa19061083349ac695a7d082540bad64d0/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c0022283e9556e0f3643b7c3c03f05063ca72b3063291834cca43234f20c60bb", size = 90001, upload-time = "2025-08-05T16:42:50.038Z" }, + { url = "https://files.pythonhosted.org/packages/58/26/4bae7f9d2f116ed5593989d0e521d679b0d583973d203384679323d8fa85/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a2d4f1513d63c795e82948e1305f31a6d530626e5f9f2605408b300ae6095093", size = 99046, upload-time = "2025-08-05T16:42:51.111Z" }, + { url = "https://files.pythonhosted.org/packages/b2/67/a9f4fb3e250dda9e9046f8866e9fa7d52664f8985e445c6b4ad6dfb55641/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:c9c8e68d8b4a56fda8c025e538e639f8c5953f5073886b596c93ec9b620055e7", size = 84788, upload-time = "2025-08-05T16:42:52.198Z" }, + { url = "https://files.pythonhosted.org/packages/70/f7/3de86562db0121956148bcb0fe5b506615e3bcf6e63c4357a612b910765a/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:96f19de485a2925314f5020e85911fb447ff5fbef56e8c7c6927851b95533a1c", size = 94472, upload-time = "2025-08-05T16:42:53.59Z" }, + { url = "https://files.pythonhosted.org/packages/f1/32/fd772bf9078ae1001207d2df1eef3da05bea611a87dd0e8217989b2848fa/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e541c3ef484852ef36545f66209444c48b28661e864ccadb29daddb6a4b8e5f5", size = 92279, upload-time = "2025-08-05T16:42:54.632Z" }, + { url = "https://files.pythonhosted.org/packages/4f/41/affea7181592ab0ab560044632571a38edaf9130b84928177823fbf3176a/audioop_lts-0.2.2-cp313-cp313t-win32.whl", hash = "sha256:d5e73fa573e273e4f2e5ff96f9043858a5e9311e94ffefd88a3186a910c70917", size = 26568, upload-time = "2025-08-05T16:42:55.627Z" }, + { url = "https://files.pythonhosted.org/packages/28/2b/0372842877016641db8fc54d5c88596b542eec2f8f6c20a36fb6612bf9ee/audioop_lts-0.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9191d68659eda01e448188f60364c7763a7ca6653ed3f87ebb165822153a8547", size = 30942, upload-time = "2025-08-05T16:42:56.674Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/baf2b9cc7e96c179bb4a54f30fcd83e6ecb340031bde68f486403f943768/audioop_lts-0.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:c174e322bb5783c099aaf87faeb240c8d210686b04bd61dfd05a8e5a83d88969", size = 24603, upload-time = "2025-08-05T16:42:57.571Z" }, + { url = "https://files.pythonhosted.org/packages/5c/73/413b5a2804091e2c7d5def1d618e4837f1cb82464e230f827226278556b7/audioop_lts-0.2.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:f9ee9b52f5f857fbaf9d605a360884f034c92c1c23021fb90b2e39b8e64bede6", size = 47104, upload-time = "2025-08-05T16:42:58.518Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8c/daa3308dc6593944410c2c68306a5e217f5c05b70a12e70228e7dd42dc5c/audioop_lts-0.2.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:49ee1a41738a23e98d98b937a0638357a2477bc99e61b0f768a8f654f45d9b7a", size = 27754, upload-time = "2025-08-05T16:43:00.132Z" }, + { url = "https://files.pythonhosted.org/packages/4e/86/c2e0f627168fcf61781a8f72cab06b228fe1da4b9fa4ab39cfb791b5836b/audioop_lts-0.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5b00be98ccd0fc123dcfad31d50030d25fcf31488cde9e61692029cd7394733b", size = 27332, upload-time = "2025-08-05T16:43:01.666Z" }, + { url = "https://files.pythonhosted.org/packages/c7/bd/35dce665255434f54e5307de39e31912a6f902d4572da7c37582809de14f/audioop_lts-0.2.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6d2e0f9f7a69403e388894d4ca5ada5c47230716a03f2847cfc7bd1ecb589d6", size = 92396, upload-time = "2025-08-05T16:43:02.991Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d2/deeb9f51def1437b3afa35aeb729d577c04bcd89394cb56f9239a9f50b6f/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9b0b8a03ef474f56d1a842af1a2e01398b8f7654009823c6d9e0ecff4d5cfbf", size = 91811, upload-time = "2025-08-05T16:43:04.096Z" }, + { url = "https://files.pythonhosted.org/packages/76/3b/09f8b35b227cee28cc8231e296a82759ed80c1a08e349811d69773c48426/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b267b70747d82125f1a021506565bdc5609a2b24bcb4773c16d79d2bb260bbd", size = 100483, upload-time = "2025-08-05T16:43:05.085Z" }, + { url = "https://files.pythonhosted.org/packages/0b/15/05b48a935cf3b130c248bfdbdea71ce6437f5394ee8533e0edd7cfd93d5e/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0337d658f9b81f4cd0fdb1f47635070cc084871a3d4646d9de74fdf4e7c3d24a", size = 103885, upload-time = "2025-08-05T16:43:06.197Z" }, + { url = "https://files.pythonhosted.org/packages/83/80/186b7fce6d35b68d3d739f228dc31d60b3412105854edb975aa155a58339/audioop_lts-0.2.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:167d3b62586faef8b6b2275c3218796b12621a60e43f7e9d5845d627b9c9b80e", size = 84899, upload-time = "2025-08-05T16:43:07.291Z" }, + { url = "https://files.pythonhosted.org/packages/49/89/c78cc5ac6cb5828f17514fb12966e299c850bc885e80f8ad94e38d450886/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0d9385e96f9f6da847f4d571ce3cb15b5091140edf3db97276872647ce37efd7", size = 89998, upload-time = "2025-08-05T16:43:08.335Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4b/6401888d0c010e586c2ca50fce4c903d70a6bb55928b16cfbdfd957a13da/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:48159d96962674eccdca9a3df280e864e8ac75e40a577cc97c5c42667ffabfc5", size = 99046, upload-time = "2025-08-05T16:43:09.367Z" }, + { url = "https://files.pythonhosted.org/packages/de/f8/c874ca9bb447dae0e2ef2e231f6c4c2b0c39e31ae684d2420b0f9e97ee68/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8fefe5868cd082db1186f2837d64cfbfa78b548ea0d0543e9b28935ccce81ce9", size = 84843, upload-time = "2025-08-05T16:43:10.749Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c0/0323e66f3daebc13fd46b36b30c3be47e3fc4257eae44f1e77eb828c703f/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:58cf54380c3884fb49fdd37dfb7a772632b6701d28edd3e2904743c5e1773602", size = 94490, upload-time = "2025-08-05T16:43:12.131Z" }, + { url = "https://files.pythonhosted.org/packages/98/6b/acc7734ac02d95ab791c10c3f17ffa3584ccb9ac5c18fd771c638ed6d1f5/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:088327f00488cdeed296edd9215ca159f3a5a5034741465789cad403fcf4bec0", size = 92297, upload-time = "2025-08-05T16:43:13.139Z" }, + { url = "https://files.pythonhosted.org/packages/13/c3/c3dc3f564ce6877ecd2a05f8d751b9b27a8c320c2533a98b0c86349778d0/audioop_lts-0.2.2-cp314-cp314t-win32.whl", hash = "sha256:068aa17a38b4e0e7de771c62c60bbca2455924b67a8814f3b0dee92b5820c0b3", size = 27331, upload-time = "2025-08-05T16:43:14.19Z" }, + { url = "https://files.pythonhosted.org/packages/72/bb/b4608537e9ffcb86449091939d52d24a055216a36a8bf66b936af8c3e7ac/audioop_lts-0.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a5bf613e96f49712073de86f20dbdd4014ca18efd4d34ed18c75bd808337851b", size = 31697, upload-time = "2025-08-05T16:43:15.193Z" }, + { url = "https://files.pythonhosted.org/packages/f6/22/91616fe707a5c5510de2cac9b046a30defe7007ba8a0c04f9c08f27df312/audioop_lts-0.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:b492c3b040153e68b9fdaff5913305aaaba5bb433d8a7f73d5cf6a64ed3cc1dd", size = 25206, upload-time = "2025-08-05T16:43:16.444Z" }, +] + +[[package]] +name = "audioread" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "standard-aifc", marker = "python_full_version >= '3.13'" }, + { name = "standard-sunau", marker = "python_full_version >= '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/4a/874ecf9b472f998130c2b5e145dcdb9f6131e84786111489103b66772143/audioread-3.1.0.tar.gz", hash = "sha256:1c4ab2f2972764c896a8ac61ac53e261c8d29f0c6ccd652f84e18f08a4cab190", size = 20082, upload-time = "2025-10-26T19:44:13.484Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/16/fbe8e1e185a45042f7cd3a282def5bb8d95bb69ab9e9ef6a5368aa17e426/audioread-3.1.0-py3-none-any.whl", hash = "sha256:b30d1df6c5d3de5dcef0fb0e256f6ea17bdcf5f979408df0297d8a408e2971b4", size = 23143, upload-time = "2025-10-26T19:44:12.016Z" }, +] + [[package]] name = "backports-tarfile" version = "1.2.0" @@ -331,6 +410,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ba/af/72cd6ef29f9c5f731251acadaeb821559fe25f10852f44a63374c9ca08c1/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9", size = 4409447, upload-time = "2025-10-15T23:18:24.209Z" }, ] +[[package]] +name = "dacite" +version = "1.9.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/55/a0/7ca79796e799a3e782045d29bf052b5cde7439a2bbb17f15ff44f7aacc63/dacite-1.9.2.tar.gz", hash = "sha256:6ccc3b299727c7aa17582f0021f6ae14d5de47c7227932c47fec4cdfefd26f09", size = 22420, upload-time = "2025-02-05T09:27:29.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/35/386550fd60316d1e37eccdda609b074113298f23cef5bddb2049823fe666/dacite-1.9.2-py3-none-any.whl", hash = "sha256:053f7c3f5128ca2e9aceb66892b1a3c8936d02c686e707bee96e19deef4bc4a0", size = 16600, upload-time = "2025-02-05T09:27:24.345Z" }, +] + +[[package]] +name = "decorator" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084, upload-time = "2026-05-18T06:03:28.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" }, +] + [[package]] name = "distlib" version = "0.4.0" @@ -584,6 +681,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "joblib" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, +] + [[package]] name = "keyring" version = "25.7.0" @@ -602,6 +708,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, ] +[[package]] +name = "lazy-loader" +version = "0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/ac/21a1f8aa3777f5658576777ea76bfb124b702c520bbe90edf4ae9915eafa/lazy_loader-0.5.tar.gz", hash = "sha256:717f9179a0dbed357012ddad50a5ad3d5e4d9a0b8712680d4e687f5e6e6ed9b3", size = 15294, upload-time = "2026-03-06T15:45:09.054Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/a1/8d812e53a5da1687abb10445275d41a8b13adb781bbf7196ddbcf8d88505/lazy_loader-0.5-py3-none-any.whl", hash = "sha256:ab0ea149e9c554d4ffeeb21105ac60bed7f3b4fd69b1d2360a4add51b170b005", size = 8044, upload-time = "2026-03-06T15:45:07.668Z" }, +] + +[[package]] +name = "librosa" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "audioread" }, + { name = "decorator" }, + { name = "joblib" }, + { name = "lazy-loader" }, + { name = "msgpack" }, + { name = "numba" }, + { name = "numpy" }, + { name = "pooch" }, + { name = "scikit-learn" }, + { name = "scipy" }, + { name = "soundfile" }, + { name = "soxr" }, + { name = "standard-aifc", marker = "python_full_version >= '3.13'" }, + { name = "standard-sunau", marker = "python_full_version >= '3.13'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/36/360b5aafa0238e29758729e9486c6ed92a6f37fa403b7875e06c115cdf4a/librosa-0.11.0.tar.gz", hash = "sha256:f5ed951ca189b375bbe2e33b2abd7e040ceeee302b9bbaeeffdfddb8d0ace908", size = 327001, upload-time = "2025-03-11T15:09:54.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/ba/c63c5786dfee4c3417094c4b00966e61e4a63efecee22cb7b4c0387dda83/librosa-0.11.0-py3-none-any.whl", hash = "sha256:0b6415c4fd68bff4c29288abe67c6d80b587e0e1e2cfb0aad23e4559504a7fa1", size = 260749, upload-time = "2025-03-11T15:09:52.982Z" }, +] + [[package]] name = "llvmlite" version = "0.46.0" @@ -811,6 +955,78 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, ] +[[package]] +name = "msgpack" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/23/6139781ca7aadf656fa8e384fa84693ffb13f299e6931b6526427fe5e297/msgpack-1.2.0.tar.gz", hash = "sha256:8e17af38197bf58e7e819041678f6178f4491493f5b8c8580414f40f7c2c3c41", size = 183017, upload-time = "2026-06-11T04:16:10.775Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/23/35de3182a647fcc84ab304160169edfa5dac7bbd8913fbed0a505ddc0d55/msgpack-1.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ec35cd3f127f50806aa10c3f74bf27b749f13ddf1d2217964ada8f38042d1653", size = 82368, upload-time = "2026-06-11T04:14:53.57Z" }, + { url = "https://files.pythonhosted.org/packages/aa/79/8d9bfdab933b1c7a02aba9518605a81aa30d38e9efd4915ec1a6b2d55778/msgpack-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:317eb298297121bfad9173d748124a04a36af27b6ac39c2bbc1db1ce57608dcf", size = 82095, upload-time = "2026-06-11T04:14:54.784Z" }, + { url = "https://files.pythonhosted.org/packages/d2/e1/b5accbc1354edbcee107fb35ec247db0547e91c3f90e4fabdeaee500a5a6/msgpack-1.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50fe6434de89073273026dd032a62e8b63f8857a261d7a2df5b07c9e72f3a8f7", size = 413818, upload-time = "2026-06-11T04:14:56.1Z" }, + { url = "https://files.pythonhosted.org/packages/82/31/1141cbbf7118d525834f20dcd614d1b85f1f2ffd33bc2a5ce710e6dd2516/msgpack-1.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:106c6d333ff3d4eda075b7d4b9695d1752c5bcc635e40d0dbaf4e276c9ed80e1", size = 423790, upload-time = "2026-06-11T04:14:57.509Z" }, + { url = "https://files.pythonhosted.org/packages/04/e7/9582f2bd4d7546139fe297740de49bd1f7ef2d195eb0bb9fa5efeee88158/msgpack-1.2.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:67055a611e871cb1bd0acb732f2e9f64ca8155ca0bba1d0a5bb362e7209e5541", size = 387521, upload-time = "2026-06-11T04:14:59.08Z" }, + { url = "https://files.pythonhosted.org/packages/7d/12/5aadd08ff068bfd42e2ac0be6a20aa9819965df8622e87c1f0c6119c1c22/msgpack-1.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ceec7f8e633d5a4b4a32b0416bef90ee3cd1017ea36247f705e523072e576119", size = 406324, upload-time = "2026-06-11T04:15:00.686Z" }, + { url = "https://files.pythonhosted.org/packages/39/ee/3041564f0cc4c2fe7c53315aec0edf3d84807fc9b9ea714e6ac07dbdb1db/msgpack-1.2.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7ec5851160a3c2c0f77d68ddec620318cd8e7d88d94f9c058190e8ce0dfa1d31", size = 384242, upload-time = "2026-06-11T04:15:02.121Z" }, + { url = "https://files.pythonhosted.org/packages/5d/d4/de94b3dbc266229f4c2ce84485eeb221220351b7f1931029e875995bb232/msgpack-1.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dd7140f7b09dbe1984a0dff3189375d840247e3e4cf4ac45c5a499b3b599c8d2", size = 420392, upload-time = "2026-06-11T04:15:03.692Z" }, + { url = "https://files.pythonhosted.org/packages/f7/5d/c4a3fde69a292eecb202caaa87c29df7728644a65118614b821bcaddc05a/msgpack-1.2.0-cp311-cp311-win32.whl", hash = "sha256:cbfd54018d386da0951c7a2be13de0f58559d251313e613b2155e52ed1cbd8f1", size = 63976, upload-time = "2026-06-11T04:15:05.355Z" }, + { url = "https://files.pythonhosted.org/packages/18/fa/df47f83115375e7717c985265a30f3ba096c5331518e28fb647b55c46d31/msgpack-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:653373c4614c31463ba486a67776e4bb396af289921bd5353e209534b71467fa", size = 70273, upload-time = "2026-06-11T04:15:06.529Z" }, + { url = "https://files.pythonhosted.org/packages/54/d1/ffd02e54c064aa73b6b53aa08171f92dc406727077ff275d7050c6aca28a/msgpack-1.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:7a260aea1e5e7d6c7f1d9284c7360d29021627b61dc4dd7df144b81210810537", size = 64783, upload-time = "2026-06-11T04:15:07.677Z" }, + { url = "https://files.pythonhosted.org/packages/44/07/dcb13f37e670257c8d0e944f116c799c34ac6968ecb48c83619f7e91d8b5/msgpack-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e2d6047ccd11a12c96a69f2bfe026471abef67334c3d0494a93e5310e45140a2", size = 82888, upload-time = "2026-06-11T04:15:08.992Z" }, + { url = "https://files.pythonhosted.org/packages/84/5f/6643b2a6a36ca4bc73c7674831be1d4d581cceecc7eb019dba1915951739/msgpack-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0347e3ac0dfee99086d3b68fe959da3f5f657c0019ddbaeaaa259a85f8603422", size = 82223, upload-time = "2026-06-11T04:15:10.182Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c8/9e1668b9897358e5ab39a18142e38be3cf15807e643757782da9f4a53cb3/msgpack-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25552ff1f2ff3dc8333e27eabb94f702da5929ed0e07969688194a3e9f12e151", size = 409700, upload-time = "2026-06-11T04:15:11.441Z" }, + { url = "https://files.pythonhosted.org/packages/38/ed/b7728573156d70b6b094233b0f38d876fc37340826cf852347ec2c7ca8ca/msgpack-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0d94420d9d52c56568159a69200af7e45eadb29615fa9d09fada140de1c38c7", size = 420090, upload-time = "2026-06-11T04:15:12.868Z" }, + { url = "https://files.pythonhosted.org/packages/3f/f7/5ea755a89868c04f9cdf6d96d2d99da4b3d198af10e76a6082dd0fceccc0/msgpack-1.2.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d16e1f2db4a9eebc07b7cc91898d71e710f2eed8358711a605fee802caff8923", size = 378538, upload-time = "2026-06-11T04:15:14.511Z" }, + { url = "https://files.pythonhosted.org/packages/80/2d/126e59332a439c94ffd682c38ca0102b23480e2784b3dac48d8959b0bbac/msgpack-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9cb2e700e85f1e27bbb5c9de6cc1c9a4bc5ac64d5404bdcbcb37a0dc7a947a3", size = 399468, upload-time = "2026-06-11T04:15:16.133Z" }, + { url = "https://files.pythonhosted.org/packages/da/f9/7abcef683a0ad2e5ab3a4940344aad9f20cdf1f42057ecb0982cf55085d6/msgpack-1.2.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:717d0b166dd176a5f786aeafff081f6439680acf5af193eb63e6266c12b04d3d", size = 374212, upload-time = "2026-06-11T04:15:17.536Z" }, + { url = "https://files.pythonhosted.org/packages/27/23/2d62cf0e971678e96f8a3cfa9bd77fb719ddb98da73790f63c53fd847ad8/msgpack-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e87c7a21654d18111eb1a89bd5c42baba42e61887365d9e89585e112b4203f9e", size = 414361, upload-time = "2026-06-11T04:15:18.99Z" }, + { url = "https://files.pythonhosted.org/packages/32/fb/f5c153f614037aaf802d291a4653ba1bb731f56feacba886f7c21c109e56/msgpack-1.2.0-cp312-cp312-win32.whl", hash = "sha256:967e0c891f5f23ab65762f2e5dc95922759c79f1ef99ef4c7e1fdd863e0d0af9", size = 64389, upload-time = "2026-06-11T04:15:20.237Z" }, + { url = "https://files.pythonhosted.org/packages/90/af/8aafce6e5544b43b84cb670aca40c8bea7eb5ae8f42bfcbdc7098739987a/msgpack-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:6c23e33cee28dcffa112ae205661da4636fd7b06bd9ad1559a890623b92d060b", size = 71185, upload-time = "2026-06-11T04:15:21.51Z" }, + { url = "https://files.pythonhosted.org/packages/ba/08/9cc94be1fc1fe3d1379d439326259aef0344274f64623a8138feb54dff68/msgpack-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:6eeb771571f63f68045433b1a35c0256b946f31ed62f006997e40b8ad8b735af", size = 64481, upload-time = "2026-06-11T04:15:22.639Z" }, + { url = "https://files.pythonhosted.org/packages/7d/26/2902c6946ab5c8fe1e46e40842dfc32b8824464ad5cd4725364fd83f7a58/msgpack-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3a1d30df1f302f2b7a7404afbac2ab76d510036c34cf34dffb01f704a7288e45", size = 82621, upload-time = "2026-06-11T04:15:23.844Z" }, + { url = "https://files.pythonhosted.org/packages/c9/59/7e6b812629d2f919e586041bffc130e1af32079f71bb20699eed54ed6d92/msgpack-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:581e317112260d8ca488d490cad9290a5682276f309c41c7de237a85ed8799c8", size = 81866, upload-time = "2026-06-11T04:15:25.032Z" }, + { url = "https://files.pythonhosted.org/packages/31/13/8c291196e60aafdbae38f482205d79432297749ac5d412fe638154fb6f1d/msgpack-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c6827d12eacc16873eba62408a1b7bbe8ecfb4a8f7ed78a631ae9bae6ad43cf2", size = 405618, upload-time = "2026-06-11T04:15:26.235Z" }, + { url = "https://files.pythonhosted.org/packages/fb/63/68f5d0ea81e167db5f59ddb94dc6f837667062113feff1c73fabf8907061/msgpack-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a186027e4279efa4c8bf06ce30605498d7d0d3af0fba0b9799dce85a3fd4a93c", size = 416468, upload-time = "2026-06-11T04:15:27.732Z" }, + { url = "https://files.pythonhosted.org/packages/73/58/567dddf5c5a2790f673bcd7d80c83466d68e5ee9a9674ebca3db8101c0c8/msgpack-1.2.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a96142c14a11cf1a509e8b9aaf72858a3b742b7613e095ce646913e88ce7bd99", size = 374464, upload-time = "2026-06-11T04:15:29.286Z" }, + { url = "https://files.pythonhosted.org/packages/0d/30/0c2342fc9092e4498045f5f60bca6ccbe4f4d87789778c2300e6fd6efe82/msgpack-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:50c220579b68a6085b95408b2eaa486b259520f55d8e363ddc9b5d7ba5a6ac6d", size = 395879, upload-time = "2026-06-11T04:15:30.973Z" }, + { url = "https://files.pythonhosted.org/packages/b9/11/9565b29b58ce3c33e177b490478b7aaeb8f726ecaaeda26d815893c1db5a/msgpack-1.2.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4dcb9d12ab100ecacdfaaf37a3d72fe8392eacc7054afc1916b12d1b747c8446", size = 371749, upload-time = "2026-06-11T04:15:32.418Z" }, + { url = "https://files.pythonhosted.org/packages/f2/da/7bade19d60b73e2ef73fb76aaf4504c112a70cb760951b7202a0c64b5111/msgpack-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a804727188ab0ebb237fadb303b743f04925a69d8c3247292d1e33e679767c15", size = 410416, upload-time = "2026-06-11T04:15:34.053Z" }, + { url = "https://files.pythonhosted.org/packages/6d/14/c0c619571c02432208a5977a8dbdd3fc65fe1369f8226ca4b6d08cca87d8/msgpack-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1a1ac6ae1fe23298f79380e7b144c8a454e5d05616b0096584f353ba2d750114", size = 64357, upload-time = "2026-06-11T04:15:35.535Z" }, + { url = "https://files.pythonhosted.org/packages/50/a5/de06718460909aa965737fec4cfe8a15dedc6544a8c55feeb6956fa0d6e3/msgpack-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:1c3c80949d79578f9dc85fd9fb91edfe6694e8a729cd5744634d59d8455fdde3", size = 71057, upload-time = "2026-06-11T04:15:36.83Z" }, + { url = "https://files.pythonhosted.org/packages/c7/52/73446b0141c94a856e22b787c56709c0815fc34f185326577e15b26d8cfe/msgpack-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:fcf8f76fa587c2395fd0057c7232dbf071241f9ad280b235adb7ab585289989e", size = 64490, upload-time = "2026-06-11T04:15:38.001Z" }, + { url = "https://files.pythonhosted.org/packages/35/3d/a7e3cdafa8c0cf36c81e2fa848ec4d30cf089459af45b390ad03f9ce6f49/msgpack-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f854fa1a8b55d75d82ef9a905d9cdbeffdf7897c088f6020bd221867da5e56a5", size = 83032, upload-time = "2026-06-11T04:15:39.38Z" }, + { url = "https://files.pythonhosted.org/packages/ca/aa/53ddfba0e347cc4b484e95f629c5850b9e800ca8390c91ffc604407acf87/msgpack-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e90df581f80f53b372d5d9d9349078d729851a3a0d0bd74f53ccb598d01e45b8", size = 82600, upload-time = "2026-06-11T04:15:40.609Z" }, + { url = "https://files.pythonhosted.org/packages/59/fd/e64c2c776e6dbad0af3c963fe0c0dd1ee1ba09efac478b233ab1db41868f/msgpack-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b276ed50d8ac75d1f134a433ae79af8557d0fa25ee5b4737da533dfc2ce382e8", size = 404342, upload-time = "2026-06-11T04:15:41.87Z" }, + { url = "https://files.pythonhosted.org/packages/1b/60/fb9a08e6ccba882dfd370a5837fe3a07572938fdfe954f0f17fdf3e574b9/msgpack-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:544d972459c92aa32e63b800d07c2d9cf2734a3be29cee3a0b478a622850e9f5", size = 412351, upload-time = "2026-06-11T04:15:43.253Z" }, + { url = "https://files.pythonhosted.org/packages/37/4d/df5c575c274fedc68ac9c6c61d045161899efad2afcdc25138efa7edde69/msgpack-1.2.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a070147cc2cf6b8a891734e0f5c8fe8f70ed8739ab30ba140b058005a6e86af4", size = 373331, upload-time = "2026-06-11T04:15:44.754Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a4/c8b98f8191e985ed2003d87664ce3c95cca41db5d0cf6bf4f54327d32ec8/msgpack-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7685e23b0f51745a751629c31713fbefdef8896b31b2bb38299dfa4ae6c0740c", size = 394654, upload-time = "2026-06-11T04:15:46.423Z" }, + { url = "https://files.pythonhosted.org/packages/d4/49/76f036720a602ea24428cfec5ec806f2487c0380b1bff0a2aa3094e15f87/msgpack-1.2.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b9204daeee8d91a7ae5acf2d2a8e3983be9a3025f38aa21bfaefbd7eea84a7dc", size = 370624, upload-time = "2026-06-11T04:15:48.062Z" }, + { url = "https://files.pythonhosted.org/packages/9f/38/40af3d29232833705a43b0fce0d07425cc280a7b92ab2b29932425b40df4/msgpack-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bfc057248609742ebbabf6bcd27fea4fd99c4980584e613c168c9b002318298f", size = 408038, upload-time = "2026-06-11T04:15:49.669Z" }, + { url = "https://files.pythonhosted.org/packages/30/b2/f140ca450524dff4d8d0eb81eb9ed75f8f3e0b1f12e49c5b01617cfa0b1c/msgpack-1.2.0-cp314-cp314-win32.whl", hash = "sha256:a3faa7edf2388337ae849239878e92f0298b4dab4488e4f1834062f9d0c410c9", size = 65823, upload-time = "2026-06-11T04:15:51.062Z" }, + { url = "https://files.pythonhosted.org/packages/4d/13/6517bf966b841c7675ded30701a068ce141f3e698a27aaa35c702d8e078b/msgpack-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:1a3effc392a57744e4681e55d05f97d5ee7b598747d718340a9b4b8a970c40e1", size = 72484, upload-time = "2026-06-11T04:15:52.289Z" }, + { url = "https://files.pythonhosted.org/packages/45/8c/1d948420fdaa24de4efdb8012a6a5bebe09c82ee002b8c2ca745e9917f1f/msgpack-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:56a318f7df6bec7b40928d6b0519961f20a510d8baabf6baa393a70444588f0a", size = 66657, upload-time = "2026-06-11T04:15:53.583Z" }, + { url = "https://files.pythonhosted.org/packages/39/16/1674faa1b7bddc19e79b465fd8e88e2cf4e3f7cae90723740701e8541068/msgpack-1.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:afa4a65ab2097795e771a74a3a81ea49534aaeba874eaf426a3332268e045ae6", size = 86093, upload-time = "2026-06-11T04:15:54.98Z" }, + { url = "https://files.pythonhosted.org/packages/dd/24/f241bcfdd9e96b2246289357c5a5e5a496189fd41c5844bee802c116aac7/msgpack-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:409550770632bb28daa70a11d0ed5763f7db38f40b06f7db9f11dd2794d01102", size = 86372, upload-time = "2026-06-11T04:15:56.381Z" }, + { url = "https://files.pythonhosted.org/packages/94/c9/57f8ab98a1b21808c27b6dd6029053e0a796ffbb9b371e460dbe997011a9/msgpack-1.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf47e3cd11ce044965a9736a322afdd390b31ed602d1c1b10211d1a841f1d587", size = 428207, upload-time = "2026-06-11T04:15:57.739Z" }, + { url = "https://files.pythonhosted.org/packages/17/6b/4fd4aa739f131ded751ca7167c8ee87d2aab32506ebbeea893b60b51d343/msgpack-1.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:204bc9f5d6e59c1718c0a4a84fc8ff71b5b4562faac257c1a68bca611ecf9b72", size = 426082, upload-time = "2026-06-11T04:15:59.356Z" }, + { url = "https://files.pythonhosted.org/packages/f9/00/db88e9a08fcd6513decaad06cbd5c168142bc3e662fb2f1aca3a563b7aa1/msgpack-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:610154307b27267266368bc1d1c7bb8aeb71da7be9356d403cb2442d9e6399f5", size = 378355, upload-time = "2026-06-11T04:16:00.916Z" }, + { url = "https://files.pythonhosted.org/packages/54/84/eee4dd703d7a600cf46159d621c070b0b9468cf3dbade4ea8272bf5232a4/msgpack-1.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6799f157bb63e79f11e2e590cfdb28423fc18dd60c270c3914b5b4586ae36f7e", size = 410848, upload-time = "2026-06-11T04:16:02.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/0a/195e2c549fd4631eb7f157d016ff15a10c4c1cf82b6d0a9b1edaef5174b1/msgpack-1.2.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:72bd844902cf0a5ac3af2ef742f253cd0b1e5bcd184f49b4fb9a6a1f7bf305e8", size = 376152, upload-time = "2026-06-11T04:16:04.041Z" }, + { url = "https://files.pythonhosted.org/packages/45/9b/bdd143fa79baec411dc658f5686fed680a18b36fcea5fccb6af1b8c7d832/msgpack-1.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3c0bd450f78d0d81722c80da6cdbf674a856967870a9db2f6c4debc4d8b3c67c", size = 417061, upload-time = "2026-06-11T04:16:05.63Z" }, + { url = "https://files.pythonhosted.org/packages/2d/ce/011ffcd8b919f55196ec53f12ae162e21c879d95afba226894314ff62c07/msgpack-1.2.0-cp314-cp314t-win32.whl", hash = "sha256:378caf74c4c718dfc17590ce68a6d710ed398ff6fcf08237de23b77755730b55", size = 70782, upload-time = "2026-06-11T04:16:07.105Z" }, + { url = "https://files.pythonhosted.org/packages/57/a8/9b8791ca96b1be6b9f659c718271e2cb7f99f73f58aad2dd0b30f750f6c0/msgpack-1.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:553b42598165c4dd3235994fd6e4b0dfb1ce5f3fd33d94ba9609442643015f38", size = 77899, upload-time = "2026-06-11T04:16:08.353Z" }, + { url = "https://files.pythonhosted.org/packages/5b/04/3fa2dffb87bf598696b86bde7cd642d0a7590520c3fa24cd19611dfebeb7/msgpack-1.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2825bb1da548d214ab8a810906b7dd69a10f3838b615a2cc46e5172d3cb44f6e", size = 71004, upload-time = "2026-06-11T04:16:09.556Z" }, +] + +[[package]] +name = "narwhals" +version = "2.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/62/3c/c4ef2164a71c1a63d7f1ae411c4082c5fa872405106db60a4b7114989ad7/narwhals-2.22.1.tar.gz", hash = "sha256:d62920805a0a43b7ff8b54b0c0d3142d796f8a9301836ada37e573d6a33cbcd9", size = 647493, upload-time = "2026-06-05T12:34:34.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/ca/36339329c4604adbcc99c899b7eb1ce1a555c499b6a6860757dc9bfed36d/narwhals-2.22.1-py3-none-any.whl", hash = "sha256:60567d774edf77db53906f89d9fbd164e66e56d66d388e1e6990f17ac33cfb53", size = 454815, upload-time = "2026-06-05T12:34:32.289Z" }, +] + [[package]] name = "networkx" version = "3.6.1" @@ -1072,6 +1288,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] +[[package]] +name = "parakeet-mlx" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dacite" }, + { name = "huggingface-hub" }, + { name = "librosa" }, + { name = "mlx" }, + { name = "numpy" }, + { name = "typer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/1a/d7ab33b3e25dcf2d97d0192fd1e718f2c6e69b5775370413e144fef54256/parakeet_mlx-0.5.2.tar.gz", hash = "sha256:6c6ba3c4d42ebf015dde515e5a83858a516add3f5b2c6bd4a7be5c4ba8bd4d02", size = 37791, upload-time = "2026-06-05T07:52:35.473Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/54/09a3501c74f1ef54a36002534661cb89a0b2f098fcad87fe64f515a0dba7/parakeet_mlx-0.5.2-py3-none-any.whl", hash = "sha256:50afb6ddb62237a6486e214482c25ef12759832fda3cd514e159938fa5970d9c", size = 37353, upload-time = "2026-06-05T07:52:34.452Z" }, +] + [[package]] name = "pathspec" version = "0.12.1" @@ -1111,6 +1344,20 @@ 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 = "pooch" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "platformdirs" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/43/85ef45e8b36c6a48546af7b266592dc32d7f67837a6514d111bced6d7d75/pooch-1.9.0.tar.gz", hash = "sha256:de46729579b9857ffd3e741987a2f6d5e0e03219892c167c6578c0091fb511ed", size = 61788, upload-time = "2026-01-30T19:15:09.649Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/2d/d4bf65e47cea8ff2c794a600c4fd1273a7902f268757c531e0ee9f18aa58/pooch-1.9.0-py3-none-any.whl", hash = "sha256:f265597baa9f760d25ceb29d0beb8186c243d6607b0f60b83ecf14078dbc703b", size = 67175, upload-time = "2026-01-30T19:15:08.36Z" }, +] + [[package]] name = "ptyprocess" version = "0.7.0" @@ -1473,6 +1720,51 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6d/63/8b41cea3afd7f58eb64ac9251668ee0073789a3bc9ac6f816c8c6fef986d/ruff-0.14.8-py3-none-win_arm64.whl", hash = "sha256:965a582c93c63fe715fd3e3f8aa37c4b776777203d8e1d8aa3cc0c14424a4b99", size = 13634522, upload-time = "2025-12-04T15:06:43.212Z" }, ] +[[package]] +name = "scikit-learn" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "narwhals" }, + { name = "numpy" }, + { name = "scipy" }, + { name = "threadpoolctl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fa/6f/37092bdb25f712817231799fc5674d8e704066a8a70c1d2d40517e18b4ab/scikit_learn-1.9.0.tar.gz", hash = "sha256:8833266989d3a5110178a9fae30783675460724d0e1efb13b14901d2c660c557", size = 7750767, upload-time = "2026-06-02T11:54:32.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/be/e844fd9586e66540a15b71924d17a6cbc1bb749e81ddd0a796bcdba4c055/scikit_learn-1.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9db6f4d34e68c8899e4cab27fdf8eafe6ed21f2ba52ceb25ea250cd237f8e47b", size = 8789686, upload-time = "2026-06-02T11:53:05.439Z" }, + { url = "https://files.pythonhosted.org/packages/42/e2/ff880f62677a17d035817d543cb0fc8727d01eccbee81c5f7fc733a9d856/scikit_learn-1.9.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f401448645a3e7bc115aa3c094097865155b34bff1cba8101857d9104e99074c", size = 8256782, upload-time = "2026-06-02T11:53:08.904Z" }, + { url = "https://files.pythonhosted.org/packages/25/64/eb40435e1a508ab1b4e284ce43ae80f6a162e5be5e38ed5a6fab467a9ea4/scikit_learn-1.9.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd3a8ef0c758555a3b23c03adaa858af32f7736785ded50ad5991f59c4ed03fa", size = 8992419, upload-time = "2026-06-02T11:53:11.551Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/4810a28e473185429e45a57eebcc91fc991b33d889cc0676063e671db03d/scikit_learn-1.9.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7e254636164090da847715a27f8e5478feb98c40a9e0ee90cbd277de9e5ceb8", size = 9281411, upload-time = "2026-06-02T11:53:15.063Z" }, + { url = "https://files.pythonhosted.org/packages/3b/67/be3d369f40d8178ba3bd86635d132e08cb5329b023e4669d9426d84bc007/scikit_learn-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:5dc1818c77575d149e25fce9ef82dd7b7263ae372f03494158668ad632a69759", size = 8272736, upload-time = "2026-06-02T11:53:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/37/79/a733f02dc2118da7e77a134b34f39f40201a353311b011d20859d2db3556/scikit_learn-1.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:366652351f092b219c248f1e72821e841960a63d8f358f1dcfd54dc1cbdbbc28", size = 7919564, upload-time = "2026-06-02T11:53:21.2Z" }, + { url = "https://files.pythonhosted.org/packages/ac/20/75f915ff375d6249e6550ac740fdbbd66159a068fd3af1400ff62036b07a/scikit_learn-1.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2bd41b0d201bc81575531b96b713d3eb5e5f50fb0b82101ff0f92294fdc236ac", size = 8741122, upload-time = "2026-06-02T11:53:24.08Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d5/2b5148f2279196775e1db2aeb85d14b70ac80e7e32b3b28e7ebeafb0901d/scikit_learn-1.9.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5be45aa4a42a68a533913a6ed736cf309de2226411c79ef8d609a5456f1939b1", size = 8261512, upload-time = "2026-06-02T11:53:27.183Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ee/5adbc77656b71f9456a2f5a7a9fdb4bcf9207a6b962889f1c2f9323afa4e/scikit_learn-1.9.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e50ed4da51974e86e940690e9a3d82e729b62b5a49f7c9bac534d515d39d86f", size = 8837603, upload-time = "2026-06-02T11:53:30.328Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c2/63fdda36c56437eeb44aaf9493c8bcd62ce230ab1598924fc626ffbfa943/scikit_learn-1.9.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:056c92bb67ad4c28463c2f2653d9701449201e7e7a9e94e321be0f71c4fef2b8", size = 9132097, upload-time = "2026-06-02T11:53:33.456Z" }, + { url = "https://files.pythonhosted.org/packages/83/a4/c8e67227c680e2259c8864ae72ff48b06e16a6f51253a22167aa02a8aa4e/scikit_learn-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:4306775fad04cc4b472a1b15af1ae9cede1540fbfcc17fbce3767cd8dc7ae283", size = 8211173, upload-time = "2026-06-02T11:53:36.602Z" }, + { url = "https://files.pythonhosted.org/packages/cf/fd/3c0863792e98e67e9184aa4029288a175935eb65443afcd30d4f143450cf/scikit_learn-1.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:26e22435f63bcdcf396b574273f29f13dd531f5ea035801f5be10ba1540a4e60", size = 7867451, upload-time = "2026-06-02T11:53:39.075Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/cf3310626b6d48d3e9be69a1223f9180360b5e6edb045f50fade723ce494/scikit_learn-1.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:80746d63bd4b6eaca54d36fe5feaf4d28bb38dc6f9470f81c7cad7c40155f119", size = 8705188, upload-time = "2026-06-02T11:53:41.964Z" }, + { url = "https://files.pythonhosted.org/packages/3e/04/5acd7ae280c5f93b6ac5ef6cdec14eef4c8d1cd91d85b3292989c94d96b1/scikit_learn-1.9.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5b934c45c252844a91d69fda3a34cff5e7307e1db10d77cb10a3980312c74713", size = 8228299, upload-time = "2026-06-02T11:53:44.817Z" }, + { url = "https://files.pythonhosted.org/packages/0c/39/ffe829a5b8ecb40a518724a997794657fdc354ada5e8fe8e64d998c0bac9/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:38c3dcb9a1ffb85505ec53d54c7b4aea0cff70050425a7760c2af661ac85df05", size = 8789690, upload-time = "2026-06-02T11:53:47.461Z" }, + { url = "https://files.pythonhosted.org/packages/1f/88/8dab5de10c638c083772a6be83a3d8106ced492f74a928c8693638e5bb50/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da76d09304a4706db7cc1e3ebaa3b6b98a67365cc11d2996c4f1e58ba47df714", size = 9087723, upload-time = "2026-06-02T11:53:50.702Z" }, + { url = "https://files.pythonhosted.org/packages/20/3f/7917ca72464038f6240ec70c29f94862d08a34a74291ae4d4ec5eb8186a0/scikit_learn-1.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:5808d98f15c6bf6d9d96d2348c1997392a5888ce7097e664105f930c4bca1277", size = 8184330, upload-time = "2026-06-02T11:53:53.396Z" }, + { url = "https://files.pythonhosted.org/packages/78/c7/15739eb2f61fda3c54639e9942414e5a19ad8a8d1f5a3266afad7cb7df80/scikit_learn-1.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:d77f54c017633791bc0225a43e2f8d03745fdcfe4880268fcc4df15f505dec2e", size = 7840653, upload-time = "2026-06-02T11:53:56.035Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/c9a35cf59b20a86fec24d306f1547b78dec194b08d367ce2a3e4854169d9/scikit_learn-1.9.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9656acd4e93f74e0b66c8a36c88830a99252dfa900044d36bc2212ae89a47162", size = 8713289, upload-time = "2026-06-02T11:53:58.788Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a7/552a7821597c632b907f7bfe8f36f9f572777af8ef8a48353041cf8e091a/scikit_learn-1.9.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:24360002ae845e7866522b0a5bbf690802e7bc388cac8663502e78aa98598aa2", size = 8245141, upload-time = "2026-06-02T11:54:01.694Z" }, + { url = "https://files.pythonhosted.org/packages/7d/79/f4a0c4fe9711154cddabf913471153af79056382ddc612cfe5ee0ff4b72e/scikit_learn-1.9.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5162ad10a418c8a282dde04c9aa06965de3e9a65f33c1440c0ae69bb1a09d913", size = 8847671, upload-time = "2026-06-02T11:54:04.448Z" }, + { url = "https://files.pythonhosted.org/packages/f0/af/4d72d9e475ac83719160c662619e4bf7b95c19507cd582e7d0167a3c3dae/scikit_learn-1.9.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fea2cc5677ab49d6f5bade978c866da44957b712d92e9635e8b4f723013c3cb", size = 9118104, upload-time = "2026-06-02T11:54:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/a2/d5/6a58eea2cb9abbb9b3f2bb8b2cfb3243d1152d69f442d256c7af71304769/scikit_learn-1.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:64fa347efc1c839c487433e40c5144d38c336e8a2b59c81aa8660373945c2673", size = 8290674, upload-time = "2026-06-02T11:54:10.087Z" }, + { url = "https://files.pythonhosted.org/packages/65/5b/d4c879cf358f1187141cf90ced473f087183489090244f50c124a2ee478b/scikit_learn-1.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:1b944b6db288f6b926e3650026ddafb988929de95d11fc2cc5fa117773c9ba42", size = 7978807, upload-time = "2026-06-02T11:54:12.769Z" }, + { url = "https://files.pythonhosted.org/packages/8a/43/bfae3121ec67ae09150d453c442c7c1cc166e9aefe056e6ab3b7728a5cfc/scikit_learn-1.9.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4ccacf04ca5f4b492158a5f28afe0ace43f81b2571e4b9a66d34848b46128949", size = 9031941, upload-time = "2026-06-02T11:54:15.436Z" }, + { url = "https://files.pythonhosted.org/packages/75/b0/20a4546eb17f3b25d3c66df15810411c14ed5065bcfab50b53c96fb627b2/scikit_learn-1.9.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:ee1a8db2c18c08e34c7412d4b10be1cac214cd4ea7dc9715a6a327eb49a37c96", size = 8613528, upload-time = "2026-06-02T11:54:18.842Z" }, + { url = "https://files.pythonhosted.org/packages/18/3c/e440e039bb82cd19004edaaad00acbde0fb9b461083c3ecf37941c557312/scikit_learn-1.9.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:147e9329ef0e39f75d4cffa02b2aa48d827832684926cd5210d9a2cb5c57246b", size = 8855050, upload-time = "2026-06-02T11:54:21.699Z" }, + { url = "https://files.pythonhosted.org/packages/43/26/b341b8dab5998da6270a3a42c2152c578501354d36f944b5856757035ef8/scikit_learn-1.9.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bad8f8b9950321b54c965fdcbac6c6c55e79e16646b49977bcf3668d3870a1a", size = 9097190, upload-time = "2026-06-02T11:54:24.454Z" }, + { url = "https://files.pythonhosted.org/packages/fb/de/b650b4d69b84468cfa2e28a3ff7b8103743029e6446ce1a97fe060ef688c/scikit_learn-1.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:78fc56eafd4edb9575d2d8950d1dd152061abb573341a1cb7e099fc40f6c6666", size = 8963204, upload-time = "2026-06-02T11:54:27.428Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f3/ff83d76d7418112e5a61326443cdda87be3545dd8d6599c95b2481a4419e/scikit_learn-1.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:051075bda8b7aab87b1906ab3d4740a1e1224a19d7b3781a576736edc94e76aa", size = 8222661, upload-time = "2026-06-02T11:54:30.192Z" }, +] + [[package]] name = "scipy" version = "1.16.3" @@ -1599,6 +1891,87 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/66/c7/16123d054aef6d445176c9122bfbe73c11087589b2413cab22aff5a7839a/sounddevice-0.5.3-py3-none-win_amd64.whl", hash = "sha256:f55ad20082efc2bdec06928e974fbcae07bc6c405409ae1334cefe7d377eb687", size = 364025, upload-time = "2025-10-19T13:23:56.362Z" }, ] +[[package]] +name = "soundfile" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, + { name = "numpy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/db/949331952a6fb1c5b12e9de80fd08747966c2039d1a61db4764fbd3981c2/soundfile-0.14.0.tar.gz", hash = "sha256:ba1c1a2d618bca5c406647c83b89f07cc8810fa506a50622a6993ba130c1de11", size = 47842, upload-time = "2026-06-06T08:58:47.869Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/d1/5e338af9ca6ed0786cd5bb03f6d60de1c325728c1189014f3b59aae7403c/soundfile-0.14.0-py2.py3-none-any.whl", hash = "sha256:8ba81ae3a89fd5ab3bef8a8eb481fbbe794e806309675a89b4df48b8d31908a8", size = 26799, upload-time = "2026-06-06T08:58:33.269Z" }, + { url = "https://files.pythonhosted.org/packages/7e/72/c6b21e58d3113596e7e8de0a08d6f1d95173492cfbca0a4db14148cbba2a/soundfile-0.14.0-py2.py3-none-macosx_10_9_x86_64.whl", hash = "sha256:19be05428da76ed61a4cad29b8e4bcf43a3e5c100089d2ec81dc961eed1b0dd4", size = 1144568, upload-time = "2026-06-06T08:58:35.231Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/dfdd6f8c748988427119f75eb860a3cedd858d1aea1fe28f39ad8559ef22/soundfile-0.14.0-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:d828d35a059626da52f1415b5faee610aeab393319cb3fc4a9aef47b619fc14c", size = 1103726, upload-time = "2026-06-06T08:58:37.948Z" }, + { url = "https://files.pythonhosted.org/packages/4a/f8/fc39fad6f879633461d27394cd1ddaf1f769ffa0597dca35872f51b16461/soundfile-0.14.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:e85724a90bc99a6e8062c0b4ddf725f53b2a3b70afd4da875e9d2cfc4e92f377", size = 1238050, upload-time = "2026-06-06T08:58:39.932Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a2/70fd4432b924684c372df8b0a45708c36c057ef3596c9eb53e0a806b980b/soundfile-0.14.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:1e38bac1853412871318e82a1ba69a8be677619b56025bbfcccdb41b6cafe82d", size = 1315963, upload-time = "2026-06-06T08:58:41.716Z" }, + { url = "https://files.pythonhosted.org/packages/d9/34/c9e80783d83eab739a9531fdee03675d53e0bf1b2ccb4bb3af5844675046/soundfile-0.14.0-py2.py3-none-win32.whl", hash = "sha256:0a6ae43c50c71b4e020cc55382925cb89451c1ed1a0c3d0f5d802da269226849", size = 902199, upload-time = "2026-06-06T08:58:43.289Z" }, + { url = "https://files.pythonhosted.org/packages/ed/97/b39c18ac1df45e755ca22b8b00e872929da5d107998a207a5e4ac831bfda/soundfile-0.14.0-py2.py3-none-win_amd64.whl", hash = "sha256:299491d3499460fb1b74bb4bd78b57ffc2d243a5fafa7b6ec1b264875c78453e", size = 1021480, upload-time = "2026-06-06T08:58:45.016Z" }, + { url = "https://files.pythonhosted.org/packages/f4/83/55c65e61cf457805ce2ec157c1c6ae17715d0851aa2374422de0538838ca/soundfile-0.14.0-py2.py3-none-win_arm64.whl", hash = "sha256:e090704718e124e7c844695236f1fce8d18a5e761eaf7c82dfcd124620805f98", size = 888858, upload-time = "2026-06-06T08:58:46.593Z" }, +] + +[[package]] +name = "soxr" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/11/27cebce4a108f77afea7c80545115536b45e3f11ebfb914f638fdd9ba847/soxr-1.1.0.tar.gz", hash = "sha256:9f228ae21c78fa9359ca98d8a5e8e91f30639e438e574133dace62c5b5309e44", size = 173067, upload-time = "2026-05-03T00:15:18.214Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/49/3e6bc84f87439f222f40b616e9a29a170f41fb564710ea510df19dc26907/soxr-1.1.0-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:34cc92208c3c412c046813e69da639c04a792c6a41fbfd7d909d359cd3e97a2d", size = 205699, upload-time = "2026-05-03T00:14:46.67Z" }, + { url = "https://files.pythonhosted.org/packages/2f/94/216f46096a85b07d1e6ba7fd44491402e912a3d688cd4f36f0a600ca155f/soxr-1.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bd30f7201eac896ebf5db7b09156e6f1a1b82601900d29d9c8449bdad8365b11", size = 167381, upload-time = "2026-05-03T00:14:48.012Z" }, + { url = "https://files.pythonhosted.org/packages/94/cb/06caa463b8181ec1981bd6376d4a873748b7008193188b8cfb60391eb131/soxr-1.1.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1577865e993f98ffb261257c3060fa76ec3db44ed3f181b16464268000424464", size = 210938, upload-time = "2026-05-03T00:14:49.768Z" }, + { url = "https://files.pythonhosted.org/packages/86/47/d5964551ca818b7f0c7ef7f3899056263b60ef098a801066350a9672ca8f/soxr-1.1.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3da87e3ffa3e41823d873b051c7ecb2acebd8d1b6b46b752f5facf10a0d84ab9", size = 245268, upload-time = "2026-05-03T00:14:51.422Z" }, + { url = "https://files.pythonhosted.org/packages/8f/29/371467eb86c7ba6810df0bfe9409bcd9c52ec5615b111190fafe23e4d2e1/soxr-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:ae30c48ac795378cf23ba3c7c640b8ff794af714ac388b9fd6b31a40b39e6e86", size = 176779, upload-time = "2026-05-03T00:14:53.09Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/f3da7973b5f1b05d2d7e94d5376b881dcbc05297900cae6c3d33d95b209b/soxr-1.1.0-cp312-abi3-macosx_10_14_x86_64.whl", hash = "sha256:e0e09fa633ce2e67df08b298afced4d184f6e753fc330f241022250f1d0d61da", size = 204124, upload-time = "2026-05-03T00:14:54.505Z" }, + { url = "https://files.pythonhosted.org/packages/03/dc/200013a74641f8774664bbcd2346c695c05c2e300ea792adcb40a293eed0/soxr-1.1.0-cp312-abi3-macosx_11_0_arm64.whl", hash = "sha256:d6a7ad82b8d5f3fcc04b1d2ca055562b96af571e1d4fa7c6c61d0fb509ac43b4", size = 165457, upload-time = "2026-05-03T00:14:56.007Z" }, + { url = "https://files.pythonhosted.org/packages/88/2b/2e5eba817a762a2ec589ff165b8bc5955b25a0ad140045f7cd8e45410543/soxr-1.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf98c0d7b7d5ef5bf072fee8d3020e8b664f2d195933ea7bc5089267c2e22a06", size = 206529, upload-time = "2026-05-03T00:14:57.646Z" }, + { url = "https://files.pythonhosted.org/packages/5c/f1/0e55195893228609c9a08c3b13b7a83a46c3a992cd00d3304f0f320cfb07/soxr-1.1.0-cp312-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b033078e86f3c4a658e5697fac8995764fad9e799563616b630136b613167f1", size = 240413, upload-time = "2026-05-03T00:14:59.363Z" }, + { url = "https://files.pythonhosted.org/packages/b0/4d/621e4150e4815246ad552d215a8a294a90143fedd19ee442cf82d3b3abc8/soxr-1.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:6ae2a174bffea94e8ead857dad85999d3f49f091774dbad5b046c0417d7092f4", size = 174357, upload-time = "2026-05-03T00:15:00.724Z" }, + { url = "https://files.pythonhosted.org/packages/76/cd/77b74f1e95af0e11e52e9a034421aece7f7b45afd15a909afd41d5a5d102/soxr-1.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a941f5aaa0b8abced24318105c1ea22576afcc1138c19f625716ce4e2f76ad64", size = 207990, upload-time = "2026-05-03T00:15:02.1Z" }, + { url = "https://files.pythonhosted.org/packages/30/86/600cc31f982288167a59972746f117790162012546f995a32b5a55394b16/soxr-1.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:feebcba99ac99adb8009d46c8f4c1956b8c167576b0ae8a6fb47502e9a6f78e7", size = 169288, upload-time = "2026-05-03T00:15:03.75Z" }, + { url = "https://files.pythonhosted.org/packages/39/e4/80cd9aae0645513db1076d4384e8b2d895faf5009218b4a04348012c54fc/soxr-1.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:52c9ca84e3dc656d83acc424574770e20ea8e0704dc3842d4e27b0fe9d3ba449", size = 211405, upload-time = "2026-05-03T00:15:05.395Z" }, + { url = "https://files.pythonhosted.org/packages/a6/d6/cc3c80ac9b2289da4cf46c5d53b05e4327e6f5560a25868d06f9e2213af1/soxr-1.1.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4977323ef9c3aa3c2a26ff5fe0191c84b8fd759daf7afb1f25a91a55ad8b730", size = 244617, upload-time = "2026-05-03T00:15:07.134Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/f7af5fae841ffe32ed8440234ea2ad6adecca3bd92b6101076268c429000/soxr-1.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e17d4ef9b0185214b2c0935605ae63f827ea423bc74964be44763d68d2b6c21e", size = 187253, upload-time = "2026-05-03T00:15:08.813Z" }, +] + +[[package]] +name = "standard-aifc" +version = "3.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "audioop-lts", marker = "python_full_version >= '3.13'" }, + { name = "standard-chunk", marker = "python_full_version >= '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/53/6050dc3dde1671eb3db592c13b55a8005e5040131f7509cef0215212cb84/standard_aifc-3.13.0.tar.gz", hash = "sha256:64e249c7cb4b3daf2fdba4e95721f811bde8bdfc43ad9f936589b7bb2fae2e43", size = 15240, upload-time = "2024-10-30T16:01:31.772Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/52/5fbb203394cc852334d1575cc020f6bcec768d2265355984dfd361968f36/standard_aifc-3.13.0-py3-none-any.whl", hash = "sha256:f7ae09cc57de1224a0dd8e3eb8f73830be7c3d0bc485de4c1f82b4a7f645ac66", size = 10492, upload-time = "2024-10-30T16:01:07.071Z" }, +] + +[[package]] +name = "standard-chunk" +version = "3.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/06/ce1bb165c1f111c7d23a1ad17204d67224baa69725bb6857a264db61beaf/standard_chunk-3.13.0.tar.gz", hash = "sha256:4ac345d37d7e686d2755e01836b8d98eda0d1a3ee90375e597ae43aaf064d654", size = 4672, upload-time = "2024-10-30T16:18:28.326Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/90/a5c1084d87767d787a6caba615aa50dc587229646308d9420c960cb5e4c0/standard_chunk-3.13.0-py3-none-any.whl", hash = "sha256:17880a26c285189c644bd5bd8f8ed2bdb795d216e3293e6dbe55bbd848e2982c", size = 4944, upload-time = "2024-10-30T16:18:26.694Z" }, +] + +[[package]] +name = "standard-sunau" +version = "3.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "audioop-lts", marker = "python_full_version >= '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/e3/ce8d38cb2d70e05ffeddc28bb09bad77cfef979eb0a299c9117f7ed4e6a9/standard_sunau-3.13.0.tar.gz", hash = "sha256:b319a1ac95a09a2378a8442f403c66f4fd4b36616d6df6ae82b8e536ee790908", size = 9368, upload-time = "2024-10-30T16:01:41.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/ae/e3707f6c1bc6f7aa0df600ba8075bfb8a19252140cd595335be60e25f9ee/standard_sunau-3.13.0-py3-none-any.whl", hash = "sha256:53af624a9529c41062f4c2fd33837f297f3baa196b0cfceffea6555654602622", size = 7364, upload-time = "2024-10-30T16:01:28.003Z" }, +] + [[package]] name = "sympy" version = "1.14.0" @@ -1611,6 +1984,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, ] +[[package]] +name = "threadpoolctl" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, +] + [[package]] name = "tiktoken" version = "0.12.0" @@ -1773,6 +2155,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4f/7e/bc19996fa86cad8801e8ffe6f1bba5836ca0160df76d0410d27432193712/trove_classifiers-2025.12.1.14-py3-none-any.whl", hash = "sha256:a8206978ede95937b9959c3aff3eb258bbf7b07dff391ddd4ea7e61f316635ab", size = 14184, upload-time = "2025-12-01T14:47:10.113Z" }, ] +[[package]] +name = "typer" +version = "0.26.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/ed/ef06584ccdd5c410df0837951ecd7e15d9a6144ea1bd4c73cecab1a89891/typer-0.26.7.tar.gz", hash = "sha256:e314a34c617e419c091b2830dda3ea1f257134ff593061a8f5b9717ab8dddb3a", size = 201709, upload-time = "2026-06-03T07:18:06.843Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/2201973529af2c954de0bb725323c3aaed6d7f0ceee8f550dec9185df013/typer-0.26.7-py3-none-any.whl", hash = "sha256:5c87cfbc5d34491c5346ebf49c23e18d56ccb863268d3a8d592b26087c2f5e58", size = 122456, upload-time = "2026-06-03T07:18:05.732Z" }, +] + [[package]] name = "typer-slim" version = "0.20.0" @@ -1858,17 +2255,28 @@ wheels = [ [[package]] name = "voice2text" -version = "0.2.0" +version = "0.3.0" source = { editable = "." } dependencies = [ { name = "loguru" }, - { name = "mlx-whisper" }, { name = "numpy" }, { name = "pynput" }, { name = "scipy" }, { name = "sounddevice" }, ] +[package.optional-dependencies] +all = [ + { name = "mlx-whisper" }, + { name = "parakeet-mlx" }, +] +parakeet = [ + { name = "parakeet-mlx" }, +] +whisper = [ + { name = "mlx-whisper" }, +] + [package.dev-dependencies] dev = [ { name = "hatch" }, @@ -1878,12 +2286,16 @@ dev = [ [package.metadata] requires-dist = [ { name = "loguru" }, - { name = "mlx-whisper" }, + { name = "mlx-whisper", marker = "extra == 'all'" }, + { name = "mlx-whisper", marker = "extra == 'whisper'" }, { name = "numpy" }, + { name = "parakeet-mlx", marker = "extra == 'all'" }, + { name = "parakeet-mlx", marker = "extra == 'parakeet'" }, { name = "pynput" }, { name = "scipy" }, { name = "sounddevice" }, ] +provides-extras = ["parakeet", "whisper", "all"] [package.metadata.requires-dev] dev = [ diff --git a/v2t/__init__.py b/v2t/__init__.py new file mode 100644 index 0000000..5ece064 --- /dev/null +++ b/v2t/__init__.py @@ -0,0 +1,3 @@ +"""v2t — local, MLX-first voice-to-text with LLM cleanup.""" + +__version__ = "0.3.0" diff --git a/v2t/__main__.py b/v2t/__main__.py new file mode 100644 index 0000000..eb53e2f --- /dev/null +++ b/v2t/__main__.py @@ -0,0 +1,3 @@ +from .cli import main + +raise SystemExit(main()) diff --git a/v2t/app.py b/v2t/app.py new file mode 100644 index 0000000..90577ec --- /dev/null +++ b/v2t/app.py @@ -0,0 +1,240 @@ +"""The push-to-talk engine: record on hotkey, transcribe, clean up, paste. + +macOS-only at runtime (osascript paste, pbcopy/pbpaste, System Events perms). +""" + +from __future__ import annotations + +import json +import os +import signal +import subprocess +import sys +import tempfile +import threading +import time + +import numpy as np +import sounddevice as sd +from loguru import logger +from scipy.io import wavfile + +from . import backends, config +from .config import Config + + +def short_model(name: str) -> str: + """parakeet-tdt-0.6b-v3 -> parakeet-v3, whisper-large-v3-turbo -> whisper-turbo.""" + tail = name.rsplit("/", 1)[-1] + for short in ("parakeet-v3", "parakeet-v2", "whisper-turbo"): + a, b = short.split("-") + if a in tail and b in tail: + return short + return tail + + +def _resolve_hotkey(name: str): + from pynput import keyboard + + keys = { + "cmd_r": keyboard.Key.cmd_r, "cmd_l": keyboard.Key.cmd_l, + "alt_r": keyboard.Key.alt_r, "alt_l": keyboard.Key.alt_l, + "ctrl_r": keyboard.Key.ctrl_r, "ctrl_l": keyboard.Key.ctrl_l, + } + if name not in keys: + raise SystemExit(f"unknown hotkey {name!r}; choose: {', '.join(keys)}") + return keys[name] + + +def check_and_request_permissions() -> None: + """Open the right System Settings panes if Accessibility/Input Monitoring are missing.""" + logger.info("Checking permissions...") + test = subprocess.run( + ["osascript", "-e", 'tell application "System Events" to return "ok"'], + capture_output=True, text=True, + ) + if "not allowed" in test.stderr.lower() or test.returncode != 0: + logger.warning("Permissions needed! Grant them to your TERMINAL APP (or SwiftBar if you start v2t from the menu).") + subprocess.run(["open", "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"]) + input("Press Enter after granting Accessibility permission...") + subprocess.run(["open", "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent"]) + input("Press Enter after granting Input Monitoring permission...") + logger.success("Permissions granted. Restart your terminal if the hotkey doesn't fire, then run again.") + sys.exit(0) + logger.success("Permissions OK") + + +class VoiceToText: + def __init__(self, cfg: Config): + self.cfg = cfg + self.stt_model = cfg.stt_model or backends.STT[cfg.backend].default_model + self.stt = None # loaded in run() + self.recording = False + self.processing = False + self.frames: list[np.ndarray] = [] + self.stream = None + self.record_start = 0.0 + self.was_playing = False + + # --- status file for the SwiftBar plugin ------------------------------- + def _write_status(self) -> None: + d = config.run_dir() + d.mkdir(parents=True, exist_ok=True) + (d / "v2t.pid").write_text(str(os.getpid())) + (d / "status.json").write_text(json.dumps({ + "pid": os.getpid(), + "backend": self.cfg.backend, + "model": short_model(self.stt_model), + "mode": self.cfg.mode, + "started": time.strftime("%Y-%m-%dT%H:%M:%S"), + })) + + def _clear_status(self) -> None: + d = config.run_dir() + for name in ("v2t.pid", "status.json"): + (d / name).unlink(missing_ok=True) + + # --- recording ---------------------------------------------------------- + def audio_callback(self, indata, frame_count, time_info, status): + if self.recording: + self.frames.append(indata.copy()) + + def start_recording(self): + if self.recording or self.processing: + return + self.recording = True + self.frames = [] + self.record_start = time.perf_counter() + if self.cfg.pause_music: + r = subprocess.run(["nowplaying-cli", "get", "playbackRate"], capture_output=True, text=True) + self.was_playing = r.stdout.strip() == "1" + if self.was_playing: + subprocess.run(["nowplaying-cli", "pause"]) + logger.info("Recording...") + self.stream = sd.InputStream( + samplerate=self.cfg.sample_rate, channels=1, dtype="float32", callback=self.audio_callback, + ) + self.stream.start() + + def stop_recording(self): + if not self.recording: + return + self.recording = False + duration = time.perf_counter() - self.record_start + if self.stream: + self.stream.stop() + self.stream.close() + self.stream = None + logger.info(f"Stopped ({duration:.1f}s)") + if self.frames: + threading.Thread(target=self.process_audio, args=(duration,), daemon=True).start() + + def process_audio(self, audio_s: float): + self.processing = True + try: + audio = np.concatenate(self.frames, axis=0) + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: + wavfile.write(f.name, self.cfg.sample_rate, (audio * 32767).astype(np.int16)) + temp_path = f.name + + logger.info("Transcribing...") + t0 = time.perf_counter() + raw_text = self.stt.transcribe(temp_path) + stt_s = time.perf_counter() - t0 + logger.info(f"Raw: {raw_text} ({stt_s:.2f}s)") + if not raw_text: + logger.warning("No speech detected") + return + + cleaned_text, cleanup_s = raw_text, 0.0 + if self.cfg.cleanup_enabled: + logger.info("Cleaning up...") + try: + cleaned_text, _ttft, cleanup_s = backends.cleanup( + raw_text, self.cfg.cleanup_model, self.cfg.mode, self.cfg.ollama_url, + ) + if not cleaned_text: + raise RuntimeError("empty response") + logger.info(f"Clean: {cleaned_text} ({cleanup_s:.2f}s)") + except Exception as e: + logger.error(f"LLM cleanup failed: {e}") + logger.warning("Falling back to raw transcription") + cleaned_text = raw_text + + self.paste_to_cursor(cleaned_text) + logger.success("Pasted!") + + if self.cfg.save_history: + config.append_history({ + "audio_s": round(audio_s, 2), "backend": self.cfg.backend, "model": self.stt_model, + "mode": self.cfg.mode, "stt_s": round(stt_s, 3), "cleanup_s": round(cleanup_s, 3), + "raw": raw_text, "clean": cleaned_text, + }) + finally: + if self.cfg.pause_music and self.was_playing: + subprocess.run(["nowplaying-cli", "play"]) + self.processing = False + + def paste_to_cursor(self, text: str) -> None: + """Copy, paste at cursor, restore the previous clipboard.""" + original = subprocess.run(["pbpaste"], capture_output=True, text=True).stdout + subprocess.run(["pbcopy"], input=text, text=True) + subprocess.run(["osascript", "-e", 'tell application "System Events" to keystroke "v" using command down']) + time.sleep(0.15) + subprocess.run(["pbcopy"], input=original, text=True) + + # --- run loop ----------------------------------------------------------- + def on_press(self, key): + if key == self.hotkey: + self.start_recording() + + def on_release(self, key): + if key == self.hotkey: + self.stop_recording() + + def warmup(self): + from scipy.io import wavfile as _wav + + logger.info("Loading models...") + t0 = time.perf_counter() + self.stt = backends.make_stt(self.cfg.backend, self.cfg.stt_model) + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: + _wav.write(f.name, self.cfg.sample_rate, np.zeros(self.cfg.sample_rate, dtype=np.int16)) + self.stt.transcribe(f.name) + logger.success(f"{self.cfg.backend} ready ({time.perf_counter()-t0:.1f}s)") + + if self.cfg.cleanup_enabled: + t0 = time.perf_counter() + try: + backends.cleanup("hi", self.cfg.cleanup_model, self.cfg.mode, self.cfg.ollama_url) + logger.success(f"cleanup model ready ({time.perf_counter()-t0:.1f}s)") + except Exception as e: + logger.warning(f"cleanup warmup failed ({e}); will retry per transcription") + + def run(self): + from pynput import keyboard + + self.hotkey = _resolve_hotkey(self.cfg.hotkey) + self.warmup() + self._write_status() + signal.signal(signal.SIGTERM, lambda *_: (self._clear_status(), sys.exit(0))) + + logger.info(f"Voice-to-Text — {self.cfg.backend} · {self.cfg.mode}") + if self.cfg.pause_music: + logger.info("Pause Music — on") + logger.info(f"Hold {self.cfg.hotkey} to record, release to transcribe and paste. Ctrl+C to quit.") + try: + with keyboard.Listener(on_press=self.on_press, on_release=self.on_release) as listener: + listener.join() + except KeyboardInterrupt: + logger.info("Shutting down...") + finally: + self._clear_status() + + +if __name__ == "__main__": + # ponytail: only the pure helper is testable off-Mac; the rest needs audio + MLX. + assert short_model("mlx-community/parakeet-tdt-0.6b-v3") == "parakeet-v3" + assert short_model("mlx-community/whisper-large-v3-turbo") == "whisper-turbo" + assert short_model("custom/my-model") == "my-model" + print("app.py: helper checks passed") diff --git a/v2t/backends.py b/v2t/backends.py new file mode 100644 index 0000000..1f14cb4 --- /dev/null +++ b/v2t/backends.py @@ -0,0 +1,127 @@ +"""Speech-to-text backends (MLX) and text cleanup (Ollama). + +STT is pluggable so models are easy to switch; Parakeet is the default because +it's the fastest thing on Apple Silicon right now. Heavy MLX imports are lazy so +`v2t config`/`v2t bench --cleanup` work without a GPU model loaded. +""" + +from __future__ import annotations + +import json +import re +import subprocess +import time +import urllib.error +import urllib.request + +PARAKEET_DEFAULT = "mlx-community/parakeet-tdt-0.6b-v3" +WHISPER_DEFAULT = "mlx-community/whisper-large-v3-turbo" + + +class ParakeetSTT: + default_model = PARAKEET_DEFAULT + + def __init__(self, model: str = ""): + try: + from parakeet_mlx import from_pretrained + except ImportError as e: + raise SystemExit("parakeet backend needs: uv tool install 'voice2text[parakeet]' (Apple Silicon).") from e + self.model = from_pretrained(model or self.default_model) + + def transcribe(self, wav_path: str) -> str: + return self.model.transcribe(wav_path).text.strip() + + +class WhisperSTT: + default_model = WHISPER_DEFAULT + + def __init__(self, model: str = ""): + try: + import mlx_whisper + except ImportError as e: + raise SystemExit("whisper backend needs: uv tool install 'voice2text[whisper]'") from e + self._mlx_whisper = mlx_whisper + self.model = model or self.default_model + + def transcribe(self, wav_path: str) -> str: + return self._mlx_whisper.transcribe(wav_path, path_or_hf_repo=self.model)["text"].strip() + + +STT = {"parakeet": ParakeetSTT, "whisper": WhisperSTT} + + +def make_stt(backend: str, model: str = ""): + if backend not in STT: + raise SystemExit(f"unknown backend {backend!r}; choose: {', '.join(STT)}") + return STT[backend](model) + + +PROMPTS = { + "strict": ( + "Clean up this transcription. Fix punctuation, remove filler words " + "(um, uh, like, you know), fix obvious mishearings, keep the meaning intact. " + "Output ONLY the cleaned text, nothing else:\n\n{text}" + ), + "casual": ( + "Lightly clean up this transcription. Only fix punctuation and remove filler " + "words (um, uh, like, you know). Do NOT restructure sentences or change word " + "order. Keep the original phrasing. Output ONLY the cleaned text, nothing " + "else:\n\n{text}" + ), +} + +_THINK = re.compile(r".*?", re.DOTALL) + + +def cleanup(text: str, model: str, mode: str = "strict", url: str = "http://localhost:11434", timeout: int = 60): + """Clean a transcription with a local Ollama model. + + Returns (clean_text, ttft_seconds, total_seconds). Streams over HTTP so we can + time the first token; if the server isn't up, falls back to `ollama run` + (which starts it) and reports ttft=None. blocks are stripped so a + thinking model can't leak reasoning into the output. + """ + prompt = PROMPTS[mode].format(text=text) + t0 = time.perf_counter() + try: + req = urllib.request.Request( + f"{url}/api/generate", + data=json.dumps({"model": model, "prompt": prompt, "stream": True}).encode(), + headers={"Content-Type": "application/json"}, + ) + ttft, parts = None, [] + with urllib.request.urlopen(req, timeout=timeout) as r: + for line in r: + if not line.strip(): + continue + chunk = json.loads(line) + if (piece := chunk.get("response", "")): + if ttft is None: + ttft = time.perf_counter() - t0 + parts.append(piece) + if chunk.get("error"): + raise RuntimeError(chunk["error"]) + if chunk.get("done"): + break + return _THINK.sub("", "".join(parts)).strip(), ttft, time.perf_counter() - t0 + except urllib.error.URLError: + # ponytail: server not reachable; the CLI auto-starts it. No streaming, so no TTFT. + res = subprocess.run(["ollama", "run", model, prompt], capture_output=True, text=True, timeout=timeout) + if res.returncode != 0: + raise RuntimeError(res.stderr.strip() or "ollama run failed") + return _THINK.sub("", res.stdout).strip(), None, time.perf_counter() - t0 + + +if __name__ == "__main__": + # ponytail: pure-logic checks only; live model calls are covered by `v2t bench`. + assert set(STT) == {"parakeet", "whisper"} + assert make_stt.__module__ # importable + assert _THINK.sub("", "nopeHello.").strip() == "Hello." + assert "filler" in PROMPTS["strict"] and "Keep the original phrasing" in PROMPTS["casual"] + try: + make_stt("bogus") + except SystemExit: + pass + else: + raise AssertionError("unknown backend must exit") + print("backends.py: all checks passed") diff --git a/v2t/bench.py b/v2t/bench.py new file mode 100644 index 0000000..656ad1c --- /dev/null +++ b/v2t/bench.py @@ -0,0 +1,209 @@ +"""Benchmark STT (MLX) and cleanup (Ollama) models -> a markdown grid. + +Each table is models-as-columns. STT needs Apple Silicon; cleanup needs Ollama. +Results are written to benchmarks/results/-.md so you can build a grid +across machines: run on each Mac, commit the file. Absolute times reflect THAT +machine — compare models within a run, not across machines. + + v2t bench # both tables, default models, samples via `say` + v2t bench --cleanup # cleanup table only (runs anywhere Ollama runs) + v2t bench --stt-models parakeet:mlx-community/parakeet-tdt-0.6b-v3 ... +""" + +from __future__ import annotations + +import argparse +import platform +import statistics +import subprocess +import time +import wave +from datetime import date +from pathlib import Path + +from . import backends, config + +STT_MODELS = [ + "parakeet:mlx-community/parakeet-tdt-0.6b-v3", + "parakeet:mlx-community/parakeet-tdt-0.6b-v2", + "whisper:mlx-community/whisper-large-v3-turbo", +] +CLEANUP_MODELS = ["qwen3:4b-instruct-2507", "qwen3:1.7b", "qwen2.5:3b"] + +# (name, text) — `say` turns these into audio so the STT bench is self-contained. +SAY_SAMPLES = [ + ("short", "Hey, can you send me the report by end of day? Thanks a lot."), + ("medium", "So basically I was thinking we could try the other approach for the migration, " + "move the read traffic over first, watch the error rates for a day, and then cut the " + "writes once we're confident nothing is on fire."), + ("long", "Right, so the plan for next quarter. First, we finish the local transcription work, " + "because the cloud dependency keeps breaking behind the corporate VPN and it's just not " + "worth fighting. Second, we ship the menu bar toggle so it's one click to start and stop. " + "Third, we benchmark every model on each machine, write the numbers down, and stop guessing " + "which one is fastest. None of this is hard, it just needs doing, and I'd rather spend the " + "time once than keep re-litigating it every single week in standup."), +] + +# Filler-laden raw transcriptions for the cleanup table. +CLEANUP_SAMPLES = [ + "Hey um I'll see you tomorrow at 9 actually no make it 10", + "So basically I was thinking we could um you know maybe try the other approach", + "yeah so the the thing is like we need to ship this by friday otherwise uh it slips again", +] + + +def host_label() -> str: + if platform.system() == "Darwin": + def sysctl(key): + return subprocess.run(["sysctl", "-n", key], capture_output=True, text=True).stdout.strip() + chip = sysctl("machdep.cpu.brand_string") or sysctl("hw.model") or "mac" + return chip.replace(" ", "-") + return platform.machine() + "-" + platform.system().lower() + + +def wav_duration(path: Path) -> float: + with wave.open(str(path)) as w: + return w.getnframes() / w.getframerate() + + +def ensure_samples(directory: Path) -> list[tuple[str, Path, float]]: + """Synthesize the SAY_SAMPLES with macOS `say` -> 16k mono wav (via afconvert).""" + directory.mkdir(parents=True, exist_ok=True) + out = [] + for name, text in SAY_SAMPLES: + wav = directory / f"{name}.wav" + if not wav.exists(): + aiff = directory / f"{name}.aiff" + subprocess.run(["say", "-o", str(aiff), text], check=True) + subprocess.run( + ["afconvert", "-f", "WAVE", "-d", "LEI16@16000", "-c", "1", str(aiff), str(wav)], + check=True, + ) + aiff.unlink(missing_ok=True) + out.append((name, wav, wav_duration(wav))) + return out + + +def _median(xs: list[float]) -> float: + return statistics.median(xs) + + +def bench_stt(model_specs: list[str], samples, repeat: int) -> dict: + """{spec: {"load": s, sample_name: (proc_s, rtf)}} — model loaded once, reused.""" + results = {} + for spec in model_specs: + backend, _, model = spec.partition(":") + print(f" STT {spec} ... loading") + t0 = time.perf_counter() + stt = backends.make_stt(backend, model) + results[spec] = {"load": time.perf_counter() - t0} + for name, wav, dur in samples: + times = [] + for _ in range(repeat): + t = time.perf_counter() + stt.transcribe(str(wav)) + times.append(time.perf_counter() - t) + proc = _median(times) + results[spec][name] = (proc, dur / proc if proc else 0.0) + print(f" {name} ({dur:.1f}s): {proc:.2f}s RTF {dur/proc:.0f}x") + return results + + +def bench_cleanup(models: list[str], samples: list[str], repeat: int, url: str) -> dict: + """{model: {sample_idx: (ttft_s, total_s)}}.""" + results = {} + for model in models: + print(f" cleanup {model} ... warming up") + backends.cleanup("hi", model, url=url) # pull/warm + results[model] = {} + for i, sample in enumerate(samples): + ttfts, totals = [], [] + for _ in range(repeat): + _text, ttft, total = backends.cleanup(sample, model, url=url) + ttfts.append(ttft if ttft is not None else total) + totals.append(total) + results[model][i] = (_median(ttfts), _median(totals)) + print(f" sample {i}: ttft {results[model][i][0]:.2f}s total {results[model][i][1]:.2f}s") + return results + + +def md_stt_table(results: dict, samples) -> str: + specs = list(results) + head = "| sample | " + " | ".join(backends.STT[s.split(':')[0]].__name__.replace("STT", "") + + " " + (s.split(':')[1].rsplit('/', 1)[-1]) for s in specs) + " |" + sep = "|---|" + "|".join("--:" for _ in specs) + "|" + rows = [f"| load | " + " | ".join(f"{results[s]['load']:.1f}s" for s in specs) + " |"] + for name, _wav, dur in samples: + cells = " | ".join(f"{results[s][name][0]:.2f}s ({results[s][name][1]:.0f}x)" for s in specs) + rows.append(f"| {name} ({dur:.1f}s) | {cells} |") + note = "\n_Cells: transcription time (real-time factor). Higher RTF = faster._" + return "\n".join(["**Speech-to-text** — time & RTF", "", head, sep, *rows]) + "\n" + note + + +def md_cleanup_table(results: dict, samples: list[str]) -> str: + models = list(results) + head = "| sample | " + " | ".join(models) + " |" + sep = "|---|" + "|".join("--:" for _ in models) + "|" + rows = [] + for i in range(len(samples)): + cells = " | ".join(f"{results[m][i][0]:.2f} / {results[m][i][1]:.2f}s" for m in models) + rows.append(f"| {i} | {cells} |") + med = " | ".join( + f"{_median([results[m][i][0] for i in range(len(samples))]):.2f} / " + f"{_median([results[m][i][1] for i in range(len(samples))]):.2f}s" for m in models + ) + rows.append(f"| **median** | {med} |") + note = "\n_Cells: time-to-first-token / total. Lower is better._" + return "\n".join(["**Text cleanup** — TTFT & total", "", head, sep, *rows]) + "\n" + note + + +def write_results(body: str, out: Path | None) -> Path: + out = out or (Path("benchmarks/results") / f"{date.today()}-{host_label()}.md") + out.parent.mkdir(parents=True, exist_ok=True) + header = f"# v2t benchmark — {host_label()} — {date.today()}\n\n_Compare models within this file; absolute times are machine-specific._\n\n" + out.write_text(header + body + "\n") + return out + + +def main(argv: list[str]) -> int: + p = argparse.ArgumentParser(prog="v2t bench") + p.add_argument("--stt", action="store_true", help="run the STT table only") + p.add_argument("--cleanup", action="store_true", help="run the cleanup table only") + p.add_argument("--stt-models", nargs="+", default=STT_MODELS, help="backend:model specs") + p.add_argument("--cleanup-models", nargs="+", default=CLEANUP_MODELS, help="ollama model names") + p.add_argument("--repeat", type=int, default=3, help="runs per cell, median reported") + p.add_argument("--audio", type=Path, default=config.home() / "bench-audio", help="sample wav dir") + p.add_argument("--url", default="http://localhost:11434", help="ollama base url") + p.add_argument("--out", type=Path, help="results markdown path") + a = p.parse_args(argv) + + do_stt = a.stt or not a.cleanup + do_cleanup = a.cleanup or not a.stt + sections = [] + + if do_stt: + print("Speech-to-text:") + samples = ensure_samples(a.audio) + sections.append(md_stt_table(bench_stt(a.stt_models, samples, a.repeat), samples)) + if do_cleanup: + print("Text cleanup:") + sections.append(md_cleanup_table(bench_cleanup(a.cleanup_models, CLEANUP_SAMPLES, a.repeat, a.url), CLEANUP_SAMPLES)) + + out = write_results("\n\n".join(sections), a.out) + print(f"\nwrote {out}") + print("\n" + "\n\n".join(sections)) + return 0 + + +if __name__ == "__main__": + # ponytail: check the pure table builders with fake data (no models needed). + fake = { + "parakeet:x/parakeet-tdt-0.6b-v3": {"load": 1.2, "short": (0.10, 60.0)}, + "whisper:x/whisper-large-v3-turbo": {"load": 3.4, "short": (0.50, 12.0)}, + } + t = md_stt_table(fake, [("short", Path("x"), 6.0)]) + assert "0.10s (60x)" in t and "load" in t, t + fakec = {"qwen3:4b-instruct-2507": {0: (0.2, 0.9)}, "qwen2.5:3b": {0: (0.3, 1.1)}} + c = md_cleanup_table(fakec, ["x"]) + assert "0.20 / 0.90s" in c and "median" in c, c + print("bench.py: table-builder checks passed") diff --git a/v2t/cli.py b/v2t/cli.py new file mode 100644 index 0000000..997be38 --- /dev/null +++ b/v2t/cli.py @@ -0,0 +1,130 @@ +"""v2t command line. + + v2t run push-to-talk (default) + v2t bench benchmark STT + cleanup models + v2t config show resolved config + paths (--init to write a template) + v2t status running/idle line for the SwiftBar plugin + v2t stop stop a running v2t +""" + +from __future__ import annotations + +import argparse +import json +import os +import signal +import sys + +from . import config + + +def cmd_run(argv: list[str]) -> int: + p = argparse.ArgumentParser(prog="v2t", description="push-to-talk voice-to-text") + p.add_argument("--config", help="path to a config.toml") + p.add_argument("--backend", choices=["parakeet", "whisper"], help="override transcription backend") + p.add_argument("--model", help="override STT model") + p.add_argument("--cleanup-model", help="override Ollama cleanup model") + p.add_argument("--no-cleanup", action="store_true", help="paste raw transcription, skip LLM cleanup") + p.add_argument("--casual", action="store_true", help="light cleanup (punctuation + fillers only)") + p.add_argument("--strict", action="store_true", help="full cleanup (restructures) — the default") + p.add_argument("--pause-music", action="store_true", help="pause media while recording (needs nowplaying-cli)") + a = p.parse_args(argv) + + if a.config: + os.environ["V2T_CONFIG"] = a.config + overrides = { + "backend": a.backend, + "stt_model": a.model, + "cleanup_model": a.cleanup_model, + "cleanup_enabled": False if a.no_cleanup else None, + "mode": "casual" if a.casual else ("strict" if a.strict else None), + "pause_music": True if a.pause_music else None, + } + cfg = config.load(overrides) + + from . import app # lazy: needs audio + MLX, unlike the commands above + + if cfg.pause_music and not _which("nowplaying-cli"): + from loguru import logger + logger.warning("nowplaying-cli not found (brew install nowplaying-cli) — music pause disabled.") + cfg.pause_music = False + app.check_and_request_permissions() + app.VoiceToText(cfg).run() + return 0 + + +def cmd_config(argv: list[str]) -> int: + p = argparse.ArgumentParser(prog="v2t config") + p.add_argument("--init", action="store_true", help="write a commented config.toml if absent") + p.add_argument("--path", action="store_true", help="print paths only") + a = p.parse_args(argv) + + if a.init: + print(f"wrote {config.write_default()}") + return 0 + print(f"home: {config.home()}") + print(f"config: {config.config_path()}{'' if config.config_path().exists() else ' (using defaults; v2t config --init to create)'}") + print(f"history: {config.history_path()}") + if not a.path: + from dataclasses import asdict + print("\n[effective config]") + for k, v in asdict(config.load()).items(): + print(f" {k} = {v!r}") + return 0 + + +def _status() -> dict | None: + path = config.run_dir() / "status.json" + if not path.exists(): + return None + data = json.loads(path.read_text()) + try: + os.kill(data["pid"], 0) # signal 0 = liveness probe + except ProcessLookupError: + for f in ("v2t.pid", "status.json"): + (config.run_dir() / f).unlink(missing_ok=True) + return None + except PermissionError: + pass # alive but owned by someone else + return data + + +def cmd_status(argv: list[str]) -> int: + s = _status() + if s is None: + print("idle") + else: + print(f"running\t{s['backend']}\t{s['model']}\t{s['mode']}") + return 0 + + +def cmd_stop(argv: list[str]) -> int: + s = _status() + if s is None: + print("not running") + return 1 + os.kill(s["pid"], signal.SIGTERM) + print(f"stopped v2t (pid {s['pid']})") + return 0 + + +def cmd_bench(argv: list[str]) -> int: + from . import bench + return bench.main(argv) + + +def _which(name: str) -> bool: + from shutil import which + return which(name) is not None + + +def main(argv: list[str] | None = None) -> int: + argv = list(sys.argv[1:] if argv is None else argv) + table = {"bench": cmd_bench, "config": cmd_config, "status": cmd_status, "stop": cmd_stop} + if argv and argv[0] in table: + return table[argv[0]](argv[1:]) + return cmd_run(argv[1:] if argv and argv[0] == "run" else argv) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/v2t/config.py b/v2t/config.py new file mode 100644 index 0000000..37bace3 --- /dev/null +++ b/v2t/config.py @@ -0,0 +1,159 @@ +"""Config + paths for v2t. + +Everything lives under ~/.v2t (or $V2T_HOME, or $XDG_CONFIG_HOME/v2t): + config.toml user settings + history/transcriptions.jsonl every transcription + metadata + run/ pid + status for the SwiftBar plugin + +Zero config works: the defaults below are the shipped behaviour +(Parakeet + Qwen3, MLX, strict cleanup). +""" + +from __future__ import annotations + +import json +import os +import tomllib +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from pathlib import Path + + +@dataclass +class Config: + backend: str = "parakeet" # parakeet | whisper + stt_model: str = "" # blank = the backend's own default + cleanup_enabled: bool = True + cleanup_model: str = "qwen3:4b-instruct-2507" + mode: str = "strict" # strict | casual + hotkey: str = "cmd_r" + sample_rate: int = 16000 + pause_music: bool = False + save_history: bool = True + ollama_url: str = "http://localhost:11434" + + +def home() -> Path: + """The v2t home directory. $V2T_HOME > $XDG_CONFIG_HOME/v2t > ~/.v2t.""" + if env := os.environ.get("V2T_HOME"): + return Path(env).expanduser() + if xdg := os.environ.get("XDG_CONFIG_HOME"): + return Path(xdg).expanduser() / "v2t" + return Path.home() / ".v2t" + + +def config_path() -> Path: + return Path(os.environ["V2T_CONFIG"]).expanduser() if "V2T_CONFIG" in os.environ else home() / "config.toml" + + +def history_path() -> Path: + return home() / "history" / "transcriptions.jsonl" + + +def run_dir() -> Path: + return home() / "run" + + +# TOML section -> Config field. Flat dataclass, sectioned file: friendlier to edit. +_SECTIONS = { + "transcription": {"backend": "backend", "model": "stt_model"}, + "cleanup": {"enabled": "cleanup_enabled", "model": "cleanup_model", "mode": "mode"}, + "hotkey": {"key": "hotkey"}, + "audio": {"sample_rate": "sample_rate"}, + "behavior": {"pause_music": "pause_music", "save_history": "save_history"}, + "ollama": {"url": "ollama_url"}, +} + + +def load(overrides: dict | None = None) -> Config: + """Defaults < config.toml < CLI overrides. Unknown keys are ignored, not fatal.""" + cfg = Config() + path = config_path() + if path.exists(): + data = tomllib.loads(path.read_text()) + for section, mapping in _SECTIONS.items(): + for key, field in mapping.items(): + if key in data.get(section, {}): + setattr(cfg, field, data[section][key]) + for field, value in (overrides or {}).items(): + if value is not None: + setattr(cfg, field, value) + return cfg + + +DEFAULT_TOML = """\ +# v2t config — every key is optional; delete what you don't override. + +[transcription] +backend = "parakeet" # parakeet (default, MLX) | whisper (needs voice2text[whisper]) +model = "" # blank = backend default (parakeet-tdt-0.6b-v3 / whisper-large-v3-turbo) + +[cleanup] +enabled = true +model = "qwen3:4b-instruct-2507" # any ollama model; use an -instruct (non-thinking) one +mode = "strict" # strict (restructures) | casual (punctuation + fillers only) + +[hotkey] +key = "cmd_r" # cmd_r | cmd_l | alt_r | alt_l | ctrl_r | ctrl_l + +[audio] +sample_rate = 16000 + +[behavior] +pause_music = false +save_history = true # append every transcription to history/transcriptions.jsonl + +[ollama] +url = "http://localhost:11434" +""" + + +def write_default(path: Path | None = None) -> Path: + """Write a commented template if absent. Never clobbers an existing file.""" + path = path or config_path() + path.parent.mkdir(parents=True, exist_ok=True) + if not path.exists(): + path.write_text(DEFAULT_TOML) + return path + + +def append_history(record: dict) -> None: + """Append one JSONL line with a UTC timestamp. Best-effort: never breaks a transcription.""" + path = history_path() + path.parent.mkdir(parents=True, exist_ok=True) + record = {"ts": datetime.now(timezone.utc).isoformat(timespec="seconds"), **record} + with path.open("a") as f: + f.write(json.dumps(record, ensure_ascii=False) + "\n") + + +if __name__ == "__main__": + # ponytail: one runnable check for the trust-boundary logic (paths + merge + io). + import tempfile + + with tempfile.TemporaryDirectory() as d: + os.environ["V2T_HOME"] = d + os.environ.pop("XDG_CONFIG_HOME", None) + os.environ.pop("V2T_CONFIG", None) + + assert home() == Path(d), "V2T_HOME must win" + assert load().backend == "parakeet", "default backend" + assert load({"mode": "casual"}).mode == "casual", "override wins" + assert load({"mode": None}).mode == "strict", "None override ignored" + + p = write_default() + assert p.exists() and "qwen3" in p.read_text(), "template written" + before = p.read_text() + write_default() + assert p.read_text() == before, "never clobbers existing config" + # config.toml round-trips through the loader + assert load().cleanup_model == "qwen3:4b-instruct-2507", "toml parsed" + + append_history({"raw": "héllo", "clean": "Hello."}) + line = json.loads(history_path().read_text().splitlines()[-1]) + assert line["clean"] == "Hello." and line["ts"].endswith("+00:00"), "history roundtrip" + + os.environ.pop("V2T_HOME") + os.environ["XDG_CONFIG_HOME"] = d + assert home() == Path(d) / "v2t", "XDG fallback" + + print("config.py: all checks passed") diff --git a/voice2text.py b/voice2text.py deleted file mode 100644 index d9abed8..0000000 --- a/voice2text.py +++ /dev/null @@ -1,291 +0,0 @@ -""" -Voice-to-text with push-to-talk hotkey. - -Hold Right Command to record, release to transcribe and paste. - -Prerequisites: - brew install ollama - ollama pull qwen2.5:3b -""" - -import subprocess -import tempfile -import threading -import sys -import time -import argparse -import numpy as np -import sounddevice as sd -import mlx_whisper -from scipy.io import wavfile -from pynput import keyboard -from loguru import logger - -# Config -SAMPLE_RATE = 16000 -WHISPER_MODEL = "mlx-community/whisper-large-v3-turbo" -OLLAMA_MODEL = "qwen2.5:3b" -PUSH_TO_TALK_KEY = keyboard.Key.cmd_r - -CLEANUP_PROMPT_STRICT = """Clean up this transcription. Fix punctuation, remove filler words (um, uh, like, you know), fix obvious mishearings, keep the meaning intact. Output ONLY the cleaned text, nothing else: - -{text}""" - -CLEANUP_PROMPT_CASUAL = """Lightly clean up this transcription. Only fix punctuation and remove filler words (um, uh, like, you know). Do NOT restructure sentences or change word order. Keep the original phrasing. Output ONLY the cleaned text, nothing else: - -{text}""" - - -def check_and_request_permissions(): - """Check for required permissions and open System Settings if needed.""" - logger.info("Checking permissions...") - - test_result = subprocess.run( - ["osascript", "-e", 'tell application "System Events" to return "ok"'], - capture_output=True, - text=True - ) - - if "not allowed" in test_result.stderr.lower() or test_result.returncode != 0: - logger.warning("Permissions needed!") - logger.info("Grant permissions to your TERMINAL APP (Terminal, iTerm, Ghostty, VS Code, etc.)") - - logger.info("Opening Accessibility settings...") - subprocess.run([ - "open", - "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" - ]) - - input("Press Enter after granting Accessibility permission...") - - logger.info("Opening Input Monitoring settings...") - subprocess.run([ - "open", - "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent" - ]) - - input("Press Enter after granting Input Monitoring permission...") - - logger.success("Permissions granted. You may need to restart your terminal, then run this script again.") - sys.exit(0) - - logger.success("Permissions OK") - - -class VoiceToText: - def __init__(self, pause_music: bool = False, casual: bool = False): - self.recording = False - self.frames: list[np.ndarray] = [] - self.stream = None - self.processing = False - self.record_start = 0.0 - self.pause_music = pause_music - self.casual = casual - self.was_playing = False - - def audio_callback(self, indata, frame_count, time_info, status): - if self.recording: - self.frames.append(indata.copy()) - - def start_recording(self): - if self.recording or self.processing: - return - - self.recording = True - self.frames = [] - self.record_start = time.perf_counter() - - if self.pause_music: - # Check if something is playing before pausing - result = subprocess.run( - ["nowplaying-cli", "get", "playbackRate"], - capture_output=True, - text=True - ) - self.was_playing = result.stdout.strip() == "1" - if self.was_playing: - subprocess.run(["nowplaying-cli", "pause"]) - - logger.info("Recording...") - - self.stream = sd.InputStream( - samplerate=SAMPLE_RATE, - channels=1, - dtype="float32", - callback=self.audio_callback - ) - self.stream.start() - - def stop_recording(self): - if not self.recording: - return - - self.recording = False - duration = time.perf_counter() - self.record_start - if self.stream: - self.stream.stop() - self.stream.close() - self.stream = None - - logger.info(f"Stopped ({duration:.1f}s)") - - if self.frames: - threading.Thread(target=self.process_audio, daemon=True).start() - - def process_audio(self): - self.processing = True - - try: - audio = np.concatenate(self.frames, axis=0) - - with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: - wavfile.write(f.name, SAMPLE_RATE, (audio * 32767).astype(np.int16)) - temp_path = f.name - - logger.info("Transcribing...") - t0 = time.perf_counter() - result = mlx_whisper.transcribe(temp_path, path_or_hf_repo=WHISPER_MODEL) - raw_text = result["text"].strip() - t1 = time.perf_counter() - logger.info(f"Raw: {raw_text} ({t1-t0:.2f}s)") - - if not raw_text: - logger.warning("No speech detected") - return - - logger.info("Cleaning up...") - t0 = time.perf_counter() - prompt_template = CLEANUP_PROMPT_CASUAL if self.casual else CLEANUP_PROMPT_STRICT - prompt = prompt_template.format(text=raw_text) - - try: - result = subprocess.run( - ["ollama", "run", OLLAMA_MODEL, prompt], - capture_output=True, - text=True, - timeout=30, - ) - if result.returncode != 0: - raise Exception(f"Ollama exited with code {result.returncode}: {result.stderr}") - cleaned_text = result.stdout.strip() - if not cleaned_text: - raise Exception("Ollama returned empty response") - t1 = time.perf_counter() - logger.info(f"Clean: {cleaned_text} ({t1-t0:.2f}s)") - except Exception as e: - logger.error(f"LLM cleanup failed: {e}") - logger.warning("Falling back to raw transcription") - cleaned_text = raw_text - - self.paste_to_cursor(cleaned_text) - logger.success("Pasted!") - - finally: - if self.pause_music and self.was_playing: - subprocess.run(["nowplaying-cli", "play"]) - - self.processing = False - - def paste_to_cursor(self, text: str) -> None: - """Copy to clipboard, paste at cursor, then restore original clipboard.""" - original = subprocess.run(["pbpaste"], capture_output=True, text=True).stdout - - subprocess.run(["pbcopy"], input=text, text=True) - subprocess.run([ - "osascript", "-e", - 'tell application "System Events" to keystroke "v" using command down' - ]) - - time.sleep(0.15) - subprocess.run(["pbcopy"], input=original, text=True) - - def on_press(self, key): - if key == PUSH_TO_TALK_KEY: - self.start_recording() - - def on_release(self, key): - if key == PUSH_TO_TALK_KEY: - self.stop_recording() - - def run(self): - mode = "Casual" if self.casual else "Strict" - logger.info(f"Voice-to-Text Started - {mode} Mode") - if self.pause_music: - logger.info("Pause Music - Turned On") - logger.info("Hold Right Command to record, release to transcribe and paste. Ctrl+C to quit.") - logger.warning("Note: Right Command is a modifier key - you cannot type while holding it") - - try: - with keyboard.Listener( - on_press=self.on_press, - on_release=self.on_release - ) as listener: - listener.join() - except KeyboardInterrupt: - logger.info("Shutting down...") - sys.exit(0) - - -def main(): - parser = argparse.ArgumentParser(description="voice2text: push-to-talk transcription") - parser.add_argument( - "--pause-music", - action="store_true", - help="Pause Music/Spotify while recording, resume after paste" - ) - parser.add_argument( - "--casual", - action="store_true", - help="Light cleanup only (punctuation + filler words). Won't restructure sentences." - ) - args = parser.parse_args() - - if args.pause_music: - nowplaying_check = subprocess.run(["which", "nowplaying-cli"], capture_output=True) - if nowplaying_check.returncode != 0: - logger.warning("nowplaying-cli not found. Install with: brew install nowplaying-cli") - logger.warning("Music pause feature disabled.") - args.pause_music = False - - ollama_check = subprocess.run(["which", "ollama"], capture_output=True) - if ollama_check.returncode != 0: - logger.error("Ollama not found. Install with: brew install ollama") - sys.exit(1) - - model_check = subprocess.run( - ["ollama", "list"], - capture_output=True, - text=True - ) - if OLLAMA_MODEL.split(":")[0] not in model_check.stdout: - logger.error(f"Model not found. Pull with: ollama pull {OLLAMA_MODEL}") - sys.exit(1) - - check_and_request_permissions() - - try: - logger.info("Loading models...") - - t0 = time.perf_counter() - with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: - silence = np.zeros(SAMPLE_RATE, dtype=np.int16) - wavfile.write(f.name, SAMPLE_RATE, silence) - mlx_whisper.transcribe(f.name, path_or_hf_repo=WHISPER_MODEL) - logger.success(f"Whisper model ready ({time.perf_counter()-t0:.1f}s)") - - t0 = time.perf_counter() - subprocess.run( - ["ollama", "run", OLLAMA_MODEL, "hi"], - capture_output=True, text=True, timeout=60, - ) - logger.success(f"Ollama model ready ({time.perf_counter()-t0:.1f}s)") - - app = VoiceToText(pause_music=args.pause_music, casual=args.casual) - app.run() - except KeyboardInterrupt: - logger.info("Shutting down...") - sys.exit(0) - - -if __name__ == "__main__": - main() From 475be68577f2495544f8133f2595272317f5d1da Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 12:29:05 +0000 Subject: [PATCH 02/38] Add cross-machine benchmark grid scaffold; drop redundant import https://claude.ai/code/session_01XTW6U37PNqStHyZ9BZEM6r --- benchmarks/results/GRID.md | 43 ++++++++++++++++++++++++++++++++++++++ v2t/app.py | 4 +--- 2 files changed, 44 insertions(+), 3 deletions(-) create mode 100644 benchmarks/results/GRID.md diff --git a/benchmarks/results/GRID.md b/benchmarks/results/GRID.md new file mode 100644 index 0000000..b6a52d3 --- /dev/null +++ b/benchmarks/results/GRID.md @@ -0,0 +1,43 @@ +# Cross-machine grid + +The rollup view: rows = machines, columns = models. Fill each cell by running +`v2t bench` on that machine (it also writes a detailed `-.md` here). +Compare a column down the rows to see how a model scales with hardware. + +## Speech-to-text — transcription time on the medium clip (RTF) + +_Lower time / higher RTF is better. Needs Apple Silicon (MLX)._ + +| machine | parakeet-tdt-0.6b-v3 | parakeet-tdt-0.6b-v2 | whisper-large-v3-turbo | +|---|--:|--:|--:| +| Linux x86 (remote container) | n/a — no MLX | n/a — no MLX | n/a — no MLX | +| MacBook (personal) | _todo_ | _todo_ | _todo_ | +| MacBook (work) | _todo_ | _todo_ | _todo_ | + +## Text cleanup — TTFT / total on the median sample + +_Lower is better. Needs Ollama._ + +| machine | qwen3:4b-instruct-2507 | qwen3:1.7b | qwen2.5:3b | +|---|--:|--:|--:| +| Linux x86 (remote container) | _todo¹_ | _todo¹_ | _todo¹_ | +| MacBook (personal) | _todo_ | _todo_ | _todo_ | +| MacBook (work) | _todo_ | _todo_ | _todo_ | + +¹ The remote container can run the cleanup table once Ollama is installed +(`v2t bench --cleanup`), but CPU x86 timings are not representative of a Mac — +treat that row as a sanity check, not a target. + +--- + +**How to fill this in** + +```bash +# on each Mac: +v2t bench # both tables -> benchmarks/results/-.md +# on a Linux box with Ollama: +v2t bench --cleanup # cleanup table only +``` + +Then copy the medium-clip / median-sample numbers from each per-host file into +the rows above. diff --git a/v2t/app.py b/v2t/app.py index 90577ec..818cfc8 100644 --- a/v2t/app.py +++ b/v2t/app.py @@ -193,13 +193,11 @@ def on_release(self, key): self.stop_recording() def warmup(self): - from scipy.io import wavfile as _wav - logger.info("Loading models...") t0 = time.perf_counter() self.stt = backends.make_stt(self.cfg.backend, self.cfg.stt_model) with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: - _wav.write(f.name, self.cfg.sample_rate, np.zeros(self.cfg.sample_rate, dtype=np.int16)) + wavfile.write(f.name, self.cfg.sample_rate, np.zeros(self.cfg.sample_rate, dtype=np.int16)) self.stt.transcribe(f.name) logger.success(f"{self.cfg.backend} ready ({time.perf_counter()-t0:.1f}s)") From 1263ae2746323926b1021528f851b40d601ead4b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 12:45:17 +0000 Subject: [PATCH 03/38] Cleanup via in-process mlx-lm by default; add guided 'v2t setup' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pluggable cleanup engines: mlx (mlx-lm, in-process, default — no Ollama daemon) and ollama (optional, for those who already run it). Drops the strip; use a non-thinking model. Default mlx model: Qwen3-4B-Instruct-2507-4bit. - mlx-lm bundled into each MLX extra so the default cleanup just works. - v2t setup: guided config — pick transcription model + cleanup engine, detect Ollama and offer it, else default to mlx-lm. Writes ~/.v2t/config.toml. - bench cleanup uses engine:model specs, resilient (missing model -> n/a). - --cleanup-engine flag; history logs cleanup engine + model. - Docs (README/CHANGELOG/benchmarks/GRID) updated for mlx-lm-default cleanup. https://claude.ai/code/session_01XTW6U37PNqStHyZ9BZEM6r --- CHANGELOG.md | 7 +- README.md | 43 ++++-- benchmarks/README.md | 24 +-- benchmarks/results/GRID.md | 10 +- pyproject.toml | 9 +- uv.lock | 305 ++++++++++++++++++++++++++++--------- v2t/app.py | 16 +- v2t/backends.py | 143 ++++++++++------- v2t/bench.py | 83 ++++++---- v2t/cli.py | 70 ++++++++- v2t/config.py | 10 +- 11 files changed, 509 insertions(+), 211 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f115dc..c285ff3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,13 @@ This release turns v2t into a small, modular, MLX-first package. - **Parakeet is the default backend** (`mlx-community/parakeet-tdt-0.6b-v3`) — ~10× faster than Whisper on Apple Silicon, multilingual. Whisper stays available as the `whisper` backend. - **Pluggable STT backends** as install extras: `voice2text[parakeet]` (default), `voice2text[whisper]`, `voice2text[all]`. -- **Cleanup upgraded to `qwen3:4b-instruct-2507`** (latest small non-thinking instruct model). Stray `` blocks are stripped defensively. +- **Pluggable cleanup engines** — default is **mlx-lm, in-process** (`Qwen3-4B-Instruct-2507`): no Ollama, no daemon, same MLX stack as transcription. Ollama stays as an optional `engine = "ollama"`. Use a non-thinking model with either. +- **Guided `v2t setup`** — pick the transcription model and cleanup engine; detects Ollama and offers it, otherwise defaults to mlx-lm. Writes `~/.v2t/config.toml`. - **Config file** at `~/.v2t/config.toml` (honors `$V2T_HOME` / `$XDG_CONFIG_HOME`), with `v2t config [--init]`. - **Transcription history** — every result + metadata appended to `~/.v2t/history/transcriptions.jsonl`. - **SwiftBar plugin** (`swiftbar/v2t.5s.sh`) — menu-bar toggle, status, open config/history. -- **Benchmark harness** — `v2t bench` writes a per-machine markdown grid (STT RTF + cleanup TTFT/total). Self-contained STT inputs via macOS `say`. -- **New commands**: `v2t status`, `v2t stop`, `v2t config`, `v2t bench`. Cleanup now streams the Ollama HTTP API (so TTFT is measurable), falling back to `ollama run`. +- **Benchmark harness** — `v2t bench` writes a per-machine markdown grid (STT RTF + cleanup TTFT/total) with `engine:model` columns. Self-contained STT inputs via macOS `say`. +- **New commands**: `v2t setup`, `v2t status`, `v2t stop`, `v2t config`, `v2t bench`. ## 0.2.0 diff --git a/README.md b/README.md index 265fc88..8bc1a35 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ enough now that the basics fit in a small Python package on consumer hardware. - **Push-to-talk** — hold Right ⌘ (configurable), release to transcribe + paste. - **Parakeet (MLX)** transcription by default — ~10× faster than Whisper on Apple Silicon, English + 24 European languages. Whisper stays available as a fallback for rare languages/accents. -- **Local LLM cleanup** via Ollama (`qwen3:4b-instruct-2507`) — fixes punctuation and removes fillers. Strict or casual. +- **In-process LLM cleanup** via mlx-lm (`Qwen3-4B-Instruct-2507`) — fixes punctuation, removes fillers. No Ollama, no daemon. Strict or casual. (Ollama optional — see [Cleanup engine](#cleanup-engine).) - **Pastes at cursor**, restoring your previous clipboard. - **One config file** at `~/.v2t/config.toml`, plus a JSONL **history** of every transcription. - **SwiftBar plugin** — menu-bar toggle, status, and quick links to your config/history. @@ -34,24 +34,19 @@ enough now that the basics fit in a small Python package on consumer hardware. ## Install -Requires **macOS on Apple Silicon** (MLX) and **[Ollama](https://ollama.com)** for cleanup. +Requires **macOS on Apple Silicon** (MLX). Cleanup runs in-process via mlx-lm — nothing else to install, no daemon. Install with the backend you want (quote the brackets — zsh treats them as globs): ```bash -brew install ollama -ollama pull qwen3:4b-instruct-2507 -``` - -Then install v2t with the backend you want (quote the brackets — zsh treats them as globs): - -```bash -uv tool install 'voice2text[parakeet]' # recommended — Parakeet (default backend) +uv tool install 'voice2text[parakeet]' # recommended — Parakeet STT + in-process Qwen3 cleanup uv tool install 'voice2text[whisper]' # Whisper instead (no Parakeet) uv tool install 'voice2text[all]' # both, switch via config +v2t setup # optional: pick models, detect Ollama, write config v2t ``` > **Note on extras:** MLX is an explicit extra so the install stays Mac-first and you pick exactly -> one engine. A bare `voice2text` (no extra) installs no backend and tells you to add `[parakeet]`. +> one STT engine; each extra also pulls `mlx-lm` for the default cleanup. A bare `voice2text` (no +> extra) installs no backend and tells you to add `[parakeet]`. > ([PEP 771](https://peps.python.org/pep-0771/) "default extras" would let a bare install imply > Parakeet automatically, but it isn't supported by uv/hatchling yet.) @@ -69,8 +64,7 @@ pip install 'voice2text[parakeet]' && v2t git clone https://github.com/lucharo/voice2text.git && cd voice2text uv sync --extra parakeet && uv run v2t -# pixi (handles ollama; installs the parakeet extra) -pixi run ollama pull qwen3:4b-instruct-2507 +# pixi (installs the parakeet extra) pixi run v2t ``` @@ -84,6 +78,7 @@ v2t --no-cleanup # paste raw transcription, skip the LLM v2t --backend whisper # use the whisper backend for this run v2t --pause-music # pause media while recording (needs nowplaying-cli) +v2t setup # guided config: pick models, detect Ollama v2t status # running / idle (used by the SwiftBar plugin) v2t stop # stop a running v2t v2t config # show resolved config + paths (--init writes a template) @@ -121,7 +116,8 @@ model = "" # blank = backend default [cleanup] enabled = true -model = "qwen3:4b-instruct-2507" # any ollama model; use a non-thinking -instruct one +engine = "mlx" # mlx (in-process via mlx-lm) | ollama +model = "" # blank = engine default mode = "strict" # strict | casual [hotkey] @@ -132,6 +128,23 @@ pause_music = false save_history = true ``` +### Cleanup engine + +Cleanup runs **in-process via [mlx-lm](https://github.com/ml-explore/mlx-lm)** by default +(`Qwen3-4B-Instruct-2507`, non-thinking) — no daemon, no HTTP, same MLX stack as transcription. + +Already running **[Ollama](https://ollama.com)**? Switch to it (`v2t setup` offers this when it +detects Ollama, or edit the config): + +```toml +[cleanup] +engine = "ollama" +model = "qwen3:4b-instruct-2507" # then: ollama pull qwen3:4b-instruct-2507 +``` + +Either way, use a **non-thinking** model — a model that emits `` blocks will paste its +reasoning. The defaults don't. + ## SwiftBar menu-bar toggle ```bash @@ -152,7 +165,7 @@ TTFT/total), one column per model. Run it on each machine to build a grid. See | | default | why | |---|---|---| | transcription | `parakeet-tdt-0.6b-v3` | fastest on Apple Silicon, multilingual | -| cleanup | `qwen3:4b-instruct-2507` | latest small instruct, non-thinking | +| cleanup | `Qwen3-4B-Instruct-2507` (mlx-lm) | latest small instruct, non-thinking, no daemon | > This is **macOS / Apple Silicon-only** by design (MLX, `osascript` paste, `pbcopy`/`pbpaste`, > `nowplaying-cli`, System Settings permission URLs). Fork it for Linux/Windows if you like. diff --git a/benchmarks/README.md b/benchmarks/README.md index a664e44..7c78fb7 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -24,11 +24,14 @@ v2t bench --stt-models \ parakeet:mlx-community/parakeet-tdt-0.6b-v2 \ whisper:mlx-community/whisper-large-v3-turbo -v2t bench --cleanup-models qwen3:4b-instruct-2507 qwen3:1.7b qwen2.5:3b +v2t bench --cleanup-models \ + mlx:mlx-community/Qwen3-4B-Instruct-2507-4bit \ + ollama:qwen3:4b-instruct-2507 ``` -Each run writes `results/-.md`. Commit it. Run on each Mac (and the -Linux box, for the cleanup table) to build the grid. +Cleanup models are `engine:model` specs (`mlx:…` or `ollama:…`); a model that isn't +installed is marked `n/a` rather than aborting the run. Each run writes +`results/-.md`. Commit it. Run on each Mac to build the grid. ## Default models @@ -37,20 +40,19 @@ Linux box, for the cleanup table) to build the grid. | STT (default) | `mlx-community/parakeet-tdt-0.6b-v3` | multilingual (EN/ES/FR/DE…), fastest on Apple Silicon | | STT (alt) | `mlx-community/parakeet-tdt-0.6b-v2` | English-only, slightly higher EN accuracy | | STT (fallback) | `mlx-community/whisper-large-v3-turbo` | the old default; best for rare languages/accents | -| cleanup (default) | `qwen3:4b-instruct-2507` | latest small instruct, **non-thinking** | -| cleanup (alt) | `qwen3:1.7b` | smaller/faster | -| cleanup (old) | `qwen2.5:3b` | the previous default | +| cleanup (default) | `mlx:mlx-community/Qwen3-4B-Instruct-2507-4bit` | in-process, non-thinking, no daemon | +| cleanup (alt) | `mlx:mlx-community/Qwen2.5-3B-Instruct-4bit` | smaller, non-thinking | +| cleanup (ollama) | `ollama:qwen3:4b-instruct-2507` | if you already run Ollama | -> **Why the `-instruct-2507` Qwen3 and not plain `qwen3`?** The plain tags default -> to hybrid *thinking* mode and emit `` blocks, which add latency -> and pollute the output. The `-instruct` variants don't think. (v2t also strips -> any stray `` blocks defensively.) +> **Use a non-thinking model.** Hybrid Qwen3 tags emit `` blocks that +> add latency and pollute the output; the `-instruct-2507` and Qwen2.5-Instruct +> models above don't think. ## Method - Models loaded once, then each sample transcribed/cleaned `--repeat` times; the median is reported. - STT inputs: short / medium / long clips synthesized with `say` and converted to 16 kHz mono with `afconvert` — no audio files to ship, fully reproducible. - Cleanup inputs: three filler-laden raw transcriptions (see `v2t/bench.py`). -- TTFT comes from streaming the Ollama HTTP API and timing the first token. +- TTFT comes from streaming generation (mlx-lm or the Ollama HTTP API) and timing the first token. See `results/` for collected runs. diff --git a/benchmarks/results/GRID.md b/benchmarks/results/GRID.md index b6a52d3..3703fd3 100644 --- a/benchmarks/results/GRID.md +++ b/benchmarks/results/GRID.md @@ -18,15 +18,15 @@ _Lower time / higher RTF is better. Needs Apple Silicon (MLX)._ _Lower is better. Needs Ollama._ -| machine | qwen3:4b-instruct-2507 | qwen3:1.7b | qwen2.5:3b | +| machine | mlx:Qwen3-4B-Instruct-2507-4bit | mlx:Qwen2.5-3B-Instruct-4bit | ollama:qwen3:4b-instruct-2507 | |---|--:|--:|--:| -| Linux x86 (remote container) | _todo¹_ | _todo¹_ | _todo¹_ | +| Linux x86 (remote container) | n/a — no MLX | n/a — no MLX | _todo¹_ | | MacBook (personal) | _todo_ | _todo_ | _todo_ | | MacBook (work) | _todo_ | _todo_ | _todo_ | -¹ The remote container can run the cleanup table once Ollama is installed -(`v2t bench --cleanup`), but CPU x86 timings are not representative of a Mac — -treat that row as a sanity check, not a target. +¹ The default cleanup (mlx-lm) needs Apple Silicon. The remote Linux box can only +run the `ollama:` column, and only once Ollama is installed — CPU x86 timings +aren't representative of a Mac, so treat that cell as a sanity check, not a target. --- diff --git a/pyproject.toml b/pyproject.toml index 301e9a9..f6f617a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,11 +14,12 @@ dependencies = [ ] # MLX backends are extras (Apple Silicon only). Parakeet is the recommended -# default; install it explicitly. Picking `whisper` keeps Parakeet out. +# default; install it explicitly. Picking `whisper` keeps Parakeet out. Each +# extra also pulls mlx-lm for the default in-process cleanup (no Ollama needed). [project.optional-dependencies] -parakeet = ["parakeet-mlx"] -whisper = ["mlx-whisper"] -all = ["parakeet-mlx", "mlx-whisper"] +parakeet = ["parakeet-mlx", "mlx-lm"] +whisper = ["mlx-whisper", "mlx-lm"] +all = ["parakeet-mlx", "mlx-whisper", "mlx-lm"] [project.scripts] v2t = "v2t.cli:main" diff --git a/uv.lock b/uv.lock index dda2cdd..f28b578 100644 --- a/uv.lock +++ b/uv.lock @@ -343,14 +343,14 @@ wheels = [ [[package]] name = "click" -version = "8.3.1" +version = "8.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, ] [[package]] @@ -515,31 +515,34 @@ wheels = [ [[package]] name = "hf-xet" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5e/6e/0f11bacf08a67f7fb5ee09740f2ca54163863b07b70d579356e9222ce5d8/hf_xet-1.2.0.tar.gz", hash = "sha256:a8c27070ca547293b6890c4bf389f713f80e8c478631432962bb7f4bc0bd7d7f", size = 506020, upload-time = "2025-10-24T19:04:32.129Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/a5/85ef910a0aa034a2abcfadc360ab5ac6f6bc4e9112349bd40ca97551cff0/hf_xet-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:ceeefcd1b7aed4956ae8499e2199607765fbd1c60510752003b6cc0b8413b649", size = 2861870, upload-time = "2025-10-24T19:04:11.422Z" }, - { url = "https://files.pythonhosted.org/packages/ea/40/e2e0a7eb9a51fe8828ba2d47fe22a7e74914ea8a0db68a18c3aa7449c767/hf_xet-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b70218dd548e9840224df5638fdc94bd033552963cfa97f9170829381179c813", size = 2717584, upload-time = "2025-10-24T19:04:09.586Z" }, - { url = "https://files.pythonhosted.org/packages/a5/7d/daf7f8bc4594fdd59a8a596f9e3886133fdc68e675292218a5e4c1b7e834/hf_xet-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d40b18769bb9a8bc82a9ede575ce1a44c75eb80e7375a01d76259089529b5dc", size = 3315004, upload-time = "2025-10-24T19:04:00.314Z" }, - { url = "https://files.pythonhosted.org/packages/b1/ba/45ea2f605fbf6d81c8b21e4d970b168b18a53515923010c312c06cd83164/hf_xet-1.2.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:cd3a6027d59cfb60177c12d6424e31f4b5ff13d8e3a1247b3a584bf8977e6df5", size = 3222636, upload-time = "2025-10-24T19:03:58.111Z" }, - { url = "https://files.pythonhosted.org/packages/4a/1d/04513e3cab8f29ab8c109d309ddd21a2705afab9d52f2ba1151e0c14f086/hf_xet-1.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6de1fc44f58f6dd937956c8d304d8c2dea264c80680bcfa61ca4a15e7b76780f", size = 3408448, upload-time = "2025-10-24T19:04:20.951Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7c/60a2756d7feec7387db3a1176c632357632fbe7849fce576c5559d4520c7/hf_xet-1.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f182f264ed2acd566c514e45da9f2119110e48a87a327ca271027904c70c5832", size = 3503401, upload-time = "2025-10-24T19:04:22.549Z" }, - { url = "https://files.pythonhosted.org/packages/4e/64/48fffbd67fb418ab07451e4ce641a70de1c40c10a13e25325e24858ebe5a/hf_xet-1.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:293a7a3787e5c95d7be1857358a9130694a9c6021de3f27fa233f37267174382", size = 2900866, upload-time = "2025-10-24T19:04:33.461Z" }, - { url = "https://files.pythonhosted.org/packages/e2/51/f7e2caae42f80af886db414d4e9885fac959330509089f97cccb339c6b87/hf_xet-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:10bfab528b968c70e062607f663e21e34e2bba349e8038db546646875495179e", size = 2861861, upload-time = "2025-10-24T19:04:19.01Z" }, - { url = "https://files.pythonhosted.org/packages/6e/1d/a641a88b69994f9371bd347f1dd35e5d1e2e2460a2e350c8d5165fc62005/hf_xet-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a212e842647b02eb6a911187dc878e79c4aa0aa397e88dd3b26761676e8c1f8", size = 2717699, upload-time = "2025-10-24T19:04:17.306Z" }, - { url = "https://files.pythonhosted.org/packages/df/e0/e5e9bba7d15f0318955f7ec3f4af13f92e773fbb368c0b8008a5acbcb12f/hf_xet-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:30e06daccb3a7d4c065f34fc26c14c74f4653069bb2b194e7f18f17cbe9939c0", size = 3314885, upload-time = "2025-10-24T19:04:07.642Z" }, - { url = "https://files.pythonhosted.org/packages/21/90/b7fe5ff6f2b7b8cbdf1bd56145f863c90a5807d9758a549bf3d916aa4dec/hf_xet-1.2.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:29c8fc913a529ec0a91867ce3d119ac1aac966e098cf49501800c870328cc090", size = 3221550, upload-time = "2025-10-24T19:04:05.55Z" }, - { url = "https://files.pythonhosted.org/packages/6f/cb/73f276f0a7ce46cc6a6ec7d6c7d61cbfe5f2e107123d9bbd0193c355f106/hf_xet-1.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e159cbfcfbb29f920db2c09ed8b660eb894640d284f102ada929b6e3dc410a", size = 3408010, upload-time = "2025-10-24T19:04:28.598Z" }, - { url = "https://files.pythonhosted.org/packages/b8/1e/d642a12caa78171f4be64f7cd9c40e3ca5279d055d0873188a58c0f5fbb9/hf_xet-1.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c91d5ae931510107f148874e9e2de8a16052b6f1b3ca3c1b12f15ccb491390f", size = 3503264, upload-time = "2025-10-24T19:04:30.397Z" }, - { url = "https://files.pythonhosted.org/packages/17/b5/33764714923fa1ff922770f7ed18c2daae034d21ae6e10dbf4347c854154/hf_xet-1.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:210d577732b519ac6ede149d2f2f34049d44e8622bf14eb3d63bbcd2d4b332dc", size = 2901071, upload-time = "2025-10-24T19:04:37.463Z" }, - { url = "https://files.pythonhosted.org/packages/96/2d/22338486473df5923a9ab7107d375dbef9173c338ebef5098ef593d2b560/hf_xet-1.2.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:46740d4ac024a7ca9b22bebf77460ff43332868b661186a8e46c227fdae01848", size = 2866099, upload-time = "2025-10-24T19:04:15.366Z" }, - { url = "https://files.pythonhosted.org/packages/7f/8c/c5becfa53234299bc2210ba314eaaae36c2875e0045809b82e40a9544f0c/hf_xet-1.2.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:27df617a076420d8845bea087f59303da8be17ed7ec0cd7ee3b9b9f579dff0e4", size = 2722178, upload-time = "2025-10-24T19:04:13.695Z" }, - { url = "https://files.pythonhosted.org/packages/9a/92/cf3ab0b652b082e66876d08da57fcc6fa2f0e6c70dfbbafbd470bb73eb47/hf_xet-1.2.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3651fd5bfe0281951b988c0facbe726aa5e347b103a675f49a3fa8144c7968fd", size = 3320214, upload-time = "2025-10-24T19:04:03.596Z" }, - { url = "https://files.pythonhosted.org/packages/46/92/3f7ec4a1b6a65bf45b059b6d4a5d38988f63e193056de2f420137e3c3244/hf_xet-1.2.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d06fa97c8562fb3ee7a378dd9b51e343bc5bc8190254202c9771029152f5e08c", size = 3229054, upload-time = "2025-10-24T19:04:01.949Z" }, - { url = "https://files.pythonhosted.org/packages/0b/dd/7ac658d54b9fb7999a0ccb07ad863b413cbaf5cf172f48ebcd9497ec7263/hf_xet-1.2.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4c1428c9ae73ec0939410ec73023c4f842927f39db09b063b9482dac5a3bb737", size = 3413812, upload-time = "2025-10-24T19:04:24.585Z" }, - { url = "https://files.pythonhosted.org/packages/92/68/89ac4e5b12a9ff6286a12174c8538a5930e2ed662091dd2572bbe0a18c8a/hf_xet-1.2.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a55558084c16b09b5ed32ab9ed38421e2d87cf3f1f89815764d1177081b99865", size = 3508920, upload-time = "2025-10-24T19:04:26.927Z" }, - { url = "https://files.pythonhosted.org/packages/cb/44/870d44b30e1dcfb6a65932e3e1506c103a8a5aea9103c337e7a53180322c/hf_xet-1.2.0-cp37-abi3-win_amd64.whl", hash = "sha256:e6584a52253f72c9f52f9e549d5895ca7a471608495c4ecaa6cc73dba2b24d69", size = 2905735, upload-time = "2025-10-24T19:04:35.928Z" }, +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/2d/57fd21d84d93efb4bd0b962383790e19dd1bc053501b4264c97903b4e83e/hf_xet-1.5.1.tar.gz", hash = "sha256:51ef4500dab3764b41135ee1381a4b62ce56fc54d4c92b719b59e597d6df5bf6", size = 876636, upload-time = "2026-06-08T23:02:53.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/ee/dd9ba7beae1005e54131b7d45263cc74c8a066d47d354e6d58ae9445a388/hf_xet-1.5.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:dbf48c0d02cf0b2e568944330c60d9120c272dabe013bd892d48e25bc6797577", size = 4069485, upload-time = "2026-06-08T23:02:13.193Z" }, + { url = "https://files.pythonhosted.org/packages/b6/bc/9cae6cfeb4e03070874e73e5c97c66eb90369d3206b6a2b1ef5f96520888/hf_xet-1.5.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78e4e5192ad2b674c2e1160b651cb9134db974f8ae1835bdfbfb0166b894a43", size = 3838493, upload-time = "2026-06-08T23:02:15.282Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b4/d5c01e0eb6d9f2ca2dacd84d0d1b71e6cfbb2ef3208c968528e010e9b3d7/hf_xet-1.5.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6f7a04a8ad962422e225bc49fbbac99dc1806764b1f3e54dbd154bffa7593947", size = 4505658, upload-time = "2026-06-08T23:02:17.196Z" }, + { url = "https://files.pythonhosted.org/packages/76/c5/29a7598c0c6383c523dc22186d577f4e04267a626cd95ae60f67c00bfe66/hf_xet-1.5.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:d48199c2bf4f8df0adc55d31d1368b6ec0e4d4f45bc86b08038089c23db0bed8", size = 4292822, upload-time = "2026-06-08T23:02:18.608Z" }, + { url = "https://files.pythonhosted.org/packages/04/9a/dceaf6ca69390126b86ea825fb354b93d01163199070b7bd849225de9468/hf_xet-1.5.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:97f212a88d14bbf573619a74b7fecb238de77d08fc702e54dec6f78276ca3283", size = 4491255, upload-time = "2026-06-08T23:02:20.124Z" }, + { url = "https://files.pythonhosted.org/packages/48/a7/e5a7afaacf6c1791fdbeeac42951fb81c3d2bc482992b115dedcc86d963e/hf_xet-1.5.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f61e3665892a6c8c5e765395838b8ddf36185da835253d4bc4509a81e49fb342", size = 4711062, upload-time = "2026-06-08T23:02:21.863Z" }, + { url = "https://files.pythonhosted.org/packages/53/49/2802f8433c9742ce281bddc1e65c02c32268ca3098d66828b05e12e45ee2/hf_xet-1.5.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f4ad3ebd4c32dd2b27099d69dc7b2df821e30767e46fb6ee6a0713778243b8ff", size = 4017205, upload-time = "2026-06-08T23:02:23.495Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5a/50c71195b9fb883659f596e7252faf4c18c58e753a9013bdbf9bac5d2250/hf_xet-1.5.1-cp313-cp313t-win_arm64.whl", hash = "sha256:8298485c1e36e7e67cbd01eeb1376619b7af43d4f1ec245caae306f890a8a32d", size = 3845426, upload-time = "2026-06-08T23:02:25.124Z" }, + { url = "https://files.pythonhosted.org/packages/05/24/5e0c28f80371c17d49fed004597d9d132cb75c1f6f53db2cb95f459d2312/hf_xet-1.5.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:3474760d10e3bb6f92ff3f024fcb00c0b3e4001e9b035c7483e49a5dd17aa70f", size = 4069676, upload-time = "2026-06-08T23:02:26.759Z" }, + { url = "https://files.pythonhosted.org/packages/d2/17/261ba565b6a4d960fb478f61fdf919c0be5824645aaf1c319eca660c1611/hf_xet-1.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6762d89b9e3267dfd502b29b2a327b4525f33b17e7b509a78d94e2151a30ce30", size = 3838509, upload-time = "2026-06-08T23:02:28.573Z" }, + { url = "https://files.pythonhosted.org/packages/4e/44/7ffdc2e184b0d41fc0f683ba3936ef669ab63cf242cf36ef50e57d683668/hf_xet-1.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bf67e6ed10260cef62e852789dc91ebb03f382d5bdc4b1dbeb64763ea275e7d6", size = 4505881, upload-time = "2026-06-08T23:02:30.257Z" }, + { url = "https://files.pythonhosted.org/packages/63/b6/788060d5aa4d5e671f1a31bf69624c314eb2d8babab3aa562f9e5d53444e/hf_xet-1.5.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c6b6cd08ca095058780b50b8ce4d6cbf6787bcf27841705d58a9d32246e3e47a", size = 4292995, upload-time = "2026-06-08T23:02:31.993Z" }, + { url = "https://files.pythonhosted.org/packages/22/93/c5540cbd6b55529b7dc42f6734e88cebee21aefbea34128b66229df56c57/hf_xet-1.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e1af0de8ca6f190d4294a28b88023db64a1e2d1d719cab044baf75bec569e7a9", size = 4491570, upload-time = "2026-06-08T23:02:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/03/f3/9d8ceab30f44f36c1679b1b8683054c71a0dadc787dbf07421891742d3ca/hf_xet-1.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4f561cbbb92f80960772059864b7fb07eae879adde1b2e781ec6f86f6ac26c59", size = 4711565, upload-time = "2026-06-08T23:02:35.454Z" }, + { url = "https://files.pythonhosted.org/packages/cd/54/27ed9a5e2cc583b4df82f75a03a4df8dbf55f5a9fa1f47f1fadfb20dbeac/hf_xet-1.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:e7dbb40617410f432182d918e37c12303fe6700fd6aa6c5964e30a535a4461d6", size = 4017343, upload-time = "2026-06-08T23:02:37.14Z" }, + { url = "https://files.pythonhosted.org/packages/ae/12/ecb2fc8d45e767580e3a37faa97cb895608b614965567efb4f18cff67e27/hf_xet-1.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6071d5ccb4d8d2cbd5fea5cc798da4f0ba3f44e25369591c4e89a4987050e61d", size = 3845716, upload-time = "2026-06-08T23:02:39.073Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d8/5e54cf37434759d1f4f2ba9b66077ff9d4c4e1f37b6bd7975da5c40d94ab/hf_xet-1.5.1-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:6abd35c3221eff63836618ddfb954dcf84798603f71d8e33e3ed7b04acfdbe6e", size = 4077794, upload-time = "2026-06-08T23:02:40.656Z" }, + { url = "https://files.pythonhosted.org/packages/35/94/4b2ecfbad8f8b04701a23aefb62f540b9137d058b7e1dbef16a32676f0e9/hf_xet-1.5.1-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:94e761bbd266bf4c03cee73753916062665ce8365aa40ed321f45afcb934b41e", size = 3845354, upload-time = "2026-06-08T23:02:42.702Z" }, + { url = "https://files.pythonhosted.org/packages/de/cc/f99f4bc7295023d7bd9ebbfd51f75cc530ca262c1227666268b8208f4b77/hf_xet-1.5.1-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:892e3a3a3aecc12aded8b93cf4f9cd059282c7de0732f7d55026f3abdf474350", size = 4514864, upload-time = "2026-06-08T23:02:44.497Z" }, + { url = "https://files.pythonhosted.org/packages/cd/6e/21f7e5a2381278bd3b7b7a5a4d90038518bb6308a0c1daf5d9f8268bb178/hf_xet-1.5.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a93df2039190502835b1db8cd7e178b0b7b889fe9ab51299d5ced26e0dd879a4", size = 4303784, upload-time = "2026-06-08T23:02:46.203Z" }, + { url = "https://files.pythonhosted.org/packages/35/0e/f992bb6927ac1cb30ef74e62268f551f338bc32b2191f7c96a44c6f7283e/hf_xet-1.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0c97106032ef70467b4f6bc2d0ccc266d7613ee076afc56516c502f87ce1c4a6", size = 4500703, upload-time = "2026-06-08T23:02:47.628Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d1/90a498d05447980b977b1669246eeeeae4cfb0ea3e7a286eaba627f91bf9/hf_xet-1.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6208adb15d192b90e4c2ad2a27ed864359b2cb0f2494eb6d7c7f3699ac02e2bf", size = 4719498, upload-time = "2026-06-08T23:02:49.268Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b6/20f99cfe97cc663a711f7b33cc21d4793e51968e9a26125b4afcd77315ba/hf_xet-1.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:f7b3002f95d1c13e24bcb4537baa8f0eb3838957067c91bb4959bc004a6435f5", size = 4026419, upload-time = "2026-06-08T23:02:50.829Z" }, + { url = "https://files.pythonhosted.org/packages/f9/fa/77453694888f03e5a8c8852d1514a0894d8e81c622d39edbaf308ea0dcf4/hf_xet-1.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:93d090b57b211133f6c0dab0205ef5cb6d89162979ba75a74845045cc3063b8e", size = 3855178, upload-time = "2026-06-08T23:02:52.452Z" }, ] [[package]] @@ -572,23 +575,23 @@ wheels = [ [[package]] name = "huggingface-hub" -version = "1.2.2" +version = "1.19.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 = "shellingham" }, { name = "tqdm" }, - { name = "typer-slim" }, + { name = "typer" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/67/51/6db95c854e5eb3af8e0edfbfad7588983f63be39662054a49d5e116fb65d/huggingface_hub-1.2.2.tar.gz", hash = "sha256:b5b97bd37f4fe5b898a467373044649c94ee32006c032ce8fb835abe9d92ea28", size = 614598, upload-time = "2025-12-10T14:51:50.208Z" } +sdist = { url = "https://files.pythonhosted.org/packages/88/27/629cfe58c582f92ded066c4a07d1a057ff617118ab7973200f770bd853cb/huggingface_hub-1.19.0.tar.gz", hash = "sha256:fd771622182d40977272a923953ee3b1b13538f9f8a7f5d78398f10af0f1c0bd", size = 824721, upload-time = "2026-06-11T12:33:18.665Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/71/40/eb2f3a2c09bebf2fc989ba8bf701ce1f56b2f054b51e1a0fcb3e5d23f13a/huggingface_hub-1.2.2-py3-none-any.whl", hash = "sha256:0f55d7d22058fbf8b29d8095aeee80a7b695aa764f906a21e886c1f87223718f", size = 520964, upload-time = "2025-12-10T14:51:48.206Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a5/558da89f66464d8d0229ff497e8b8666977de2d8cf48c28a2862ecf1250f/huggingface_hub-1.19.0-py3-none-any.whl", hash = "sha256:1dc72e1f6b4d6df6b30eb72e57d00514ef453d660f04af2b87f0e67267f31ee0", size = 693398, upload-time = "2026-06-11T12:33:16.695Z" }, ] [[package]] @@ -880,42 +883,60 @@ wheels = [ [[package]] name = "mlx" -version = "0.30.0" +version = "0.31.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mlx-metal", marker = "sys_platform == 'darwin'" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/e8/69ebac29536c026489ded1ad58a6f5163b8fc10ab5eac21228f57ea9e83f/mlx-0.30.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:d5871a5f1ce5cba2f690d2c630a6672cc65a62326bcaa6db258185957a1f073f", size = 554825, upload-time = "2025-11-20T16:45:11.135Z" }, - { url = "https://files.pythonhosted.org/packages/2b/c7/db80b1e9f613baf99745e9920a3a7fe7b6c61398420ed308f24f60877a15/mlx-0.30.0-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:302c3d52b4f68b80a8ee259489369524a3381fcc9aed12b17c1c537870c81fce", size = 554821, upload-time = "2025-11-20T01:16:38.53Z" }, - { url = "https://files.pythonhosted.org/packages/8a/68/55cd5cab5a9b8b958ea995295aa3428ad65b3e893f04c27e2cdfcdc176af/mlx-0.30.0-cp311-cp311-macosx_26_0_arm64.whl", hash = "sha256:d72443a461f2e92329edf344ded8460df53fd7ec3c04ddd2353ee4ceb34c0ff2", size = 554791, upload-time = "2025-11-20T05:33:23.851Z" }, - { url = "https://files.pythonhosted.org/packages/16/91/5b79b6febcbb7e1051ecf408a8c30226ebfdf19a6e304b4cfa32309059ab/mlx-0.30.0-cp311-cp311-manylinux_2_35_aarch64.whl", hash = "sha256:9de2ab05cc9721c99a0802721aa1f0d6c305e97acd2f8ec4c05af3449ee7700d", size = 625176, upload-time = "2025-12-01T15:23:40.991Z" }, - { url = "https://files.pythonhosted.org/packages/80/17/16868ac1ea36ea3baf61f84721b4dfd98dd247a230b951ddd715981db79b/mlx-0.30.0-cp311-cp311-manylinux_2_35_x86_64.whl", hash = "sha256:0beb015a6afef2f86dfbefe65c9022dce91dbe1651e1b4330ef32434602323e0", size = 659735, upload-time = "2025-11-20T01:16:40.594Z" }, - { url = "https://files.pythonhosted.org/packages/94/a3/32c4c05d8967591e2a1a1e7e3fc9cece8821f5aea8ac8f3bcfdb203f4722/mlx-0.30.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:11fbae58b1e992afdec6709d5e281932871a0138582a794cdcc82ff895a28670", size = 554567, upload-time = "2025-11-20T16:45:12.73Z" }, - { url = "https://files.pythonhosted.org/packages/aa/b3/b6143f1c078fbc873e40e624dc428a3ada240721001414955f584afa866d/mlx-0.30.0-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:fc1994a4c9e60ccfe2ab9211c846be2ef7abc6fc3e53e1addb2cdf7468f61e7b", size = 554566, upload-time = "2025-11-20T01:16:41.877Z" }, - { url = "https://files.pythonhosted.org/packages/96/cb/aec36297ef76b4b190cc6d4cea1ed995b458bbc21cb91c58b0862f8cae7d/mlx-0.30.0-cp312-cp312-macosx_26_0_arm64.whl", hash = "sha256:a9f30c5c94b30b65f026162e91256473187e1de57c13dcc2aedb2fc33c07f2c1", size = 554593, upload-time = "2025-11-20T05:33:25.312Z" }, - { url = "https://files.pythonhosted.org/packages/40/d3/575eee4ef4b5f3dad9076a78f287affe046fd32b3bdba7a2e0af31f0d9d3/mlx-0.30.0-cp312-cp312-manylinux_2_35_aarch64.whl", hash = "sha256:f88d11e2719fdab08dbe68c9666003c8644ccd01dff973bc86e949cc16195eac", size = 612086, upload-time = "2025-12-01T15:23:42.317Z" }, - { url = "https://files.pythonhosted.org/packages/23/37/b5dd68da0e79e258f5d6a0e9f5fd4f9e5452e92c20b13b64948a965ea429/mlx-0.30.0-cp312-cp312-manylinux_2_35_x86_64.whl", hash = "sha256:1c4a2f5285bdd585aa6485a4fb5759ebc2721ba9381404ff867c136e84764e9b", size = 653994, upload-time = "2025-11-20T01:16:43.492Z" }, - { url = "https://files.pythonhosted.org/packages/33/a5/e171b2caa69b346bc1abc1bfd0b139f631f68a0ff602862dd255e7dd95ec/mlx-0.30.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f46aaa6c562ba183e2a64a0e6ba15ed54f9027d9b7b1822e9eec7f59b13d610c", size = 554595, upload-time = "2025-11-20T16:45:13.919Z" }, - { url = "https://files.pythonhosted.org/packages/3e/f4/aeb8980bbef08fc031ab1a2d043a1d76e60d49bf46728bef66cf25b26dfa/mlx-0.30.0-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:5edee9f1452703c0804e067f212c61d488b3ad7419b9fcacf337ffd35842f576", size = 554593, upload-time = "2025-11-20T01:16:45.033Z" }, - { url = "https://files.pythonhosted.org/packages/0b/b3/904235e11610c7cf2ba39eb39f32587d34048f7293d386f19d42d1e3dabc/mlx-0.30.0-cp313-cp313-macosx_26_0_arm64.whl", hash = "sha256:b8ae92b61353d756bbd0668b065a4ee5ee35867b03d63c0c61134d656ed236fe", size = 554330, upload-time = "2025-11-20T05:33:26.653Z" }, - { url = "https://files.pythonhosted.org/packages/de/0f/bb956ec9926596fe771ec67677c049e358c43f0506699b59dbed7ba9fedc/mlx-0.30.0-cp313-cp313-manylinux_2_35_aarch64.whl", hash = "sha256:f3ae4c99308ff4c006c3062ff73108b6f39414bcd90416930b917160070f759b", size = 612083, upload-time = "2025-12-01T15:23:43.369Z" }, - { url = "https://files.pythonhosted.org/packages/5e/87/3f3505d3fbf0f977b2930b3596f590f0079c3a2a253d01349f936c40985a/mlx-0.30.0-cp313-cp313-manylinux_2_35_x86_64.whl", hash = "sha256:431009c531f8bdbf56f46b85d458d5526b40ea43178dbc85f37ed55e03e716be", size = 653971, upload-time = "2025-11-20T01:16:46.292Z" }, - { url = "https://files.pythonhosted.org/packages/43/25/6fa174632f5beb583eda80902af80dc39a63e5b4b6e66c7831301751d82e/mlx-0.30.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:384819dccfd551aa1444acacedb9ed2619724b0e67fc361ab80d73b8a8a7618f", size = 558834, upload-time = "2025-11-20T16:45:15.303Z" }, - { url = "https://files.pythonhosted.org/packages/b9/00/8c93f6ba5dc37a459b378bd22d2303824aa341595ed6c4958c6a48870677/mlx-0.30.0-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:8982a7d7fd078988c12c3fc53da1670aa910dd8a7df1fc0b9fee7303394ac8ff", size = 558833, upload-time = "2025-11-20T01:16:48.748Z" }, - { url = "https://files.pythonhosted.org/packages/58/65/0ecb3a858142d7b250b46d1e5c2bb4ebff9f57b735643626b7d4653b0f11/mlx-0.30.0-cp314-cp314-macosx_26_0_arm64.whl", hash = "sha256:392af721cb0bcf600162d7258923c15d39e197a64e23b6abd171e1aea79771c5", size = 558510, upload-time = "2025-11-20T05:33:28.286Z" }, - { url = "https://files.pythonhosted.org/packages/27/a2/df61dc58caf6238d90cfa2535018fa2c52745e76421dd3ff11c034aae26f/mlx-0.30.0-cp314-cp314-manylinux_2_35_aarch64.whl", hash = "sha256:a4dae4c0404560d3df9832ee709e502022a9e5f89c4d432c576af8405d9a45ef", size = 614193, upload-time = "2025-12-01T15:23:44.688Z" }, - { url = "https://files.pythonhosted.org/packages/e1/dd/38f465477f996bb24ab133aaf79a3eb0fa13a9bd9d19504aad30c3be7ffc/mlx-0.30.0-cp314-cp314-manylinux_2_35_x86_64.whl", hash = "sha256:cf688c9dda18f2521d48f712515a47b145b37d5d0ab245eaf1a68f286af7ae66", size = 654400, upload-time = "2025-11-20T01:16:50.018Z" }, + { url = "https://files.pythonhosted.org/packages/94/89/1e77ec3ff380e8fb9e7258047374d31452a0f9828a0e370f127b07dd8288/mlx-0.31.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4a3f181b367d404e44a6bd68ef5eb573930809ac60cacd51d0c851c629b1b651", size = 586911, upload-time = "2026-04-22T03:14:29.675Z" }, + { url = "https://files.pythonhosted.org/packages/6a/41/c1907f05f8a3fc54025fb78ad68d3c4a4b931664d03c0a24f7f431cc4087/mlx-0.31.2-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:70297cbef7479429f69c966bfed10da20a6f0c2aa997eec2b4f6ba1a07caf2ef", size = 586915, upload-time = "2026-04-22T03:14:31.403Z" }, + { url = "https://files.pythonhosted.org/packages/97/b0/61ac2c14773c786fecbda28067b0207a0c654cb4d10c548808c51284d700/mlx-0.31.2-cp311-cp311-macosx_26_0_arm64.whl", hash = "sha256:c0ff158b7ac93a4b5659adbc70053498b30a5964fc45f78596398e056a96c36a", size = 587030, upload-time = "2026-04-22T03:14:32.961Z" }, + { url = "https://files.pythonhosted.org/packages/de/53/e12feb7078ee472983555fcb1da4749a2bbbc8fc5b29b78c205b96d37d1e/mlx-0.31.2-cp311-cp311-manylinux_2_35_aarch64.whl", hash = "sha256:cd5d42b0b2bee7efe1b0680a7e302943dd33b92c879cffa0358ffdb5a4a8d27b", size = 652994, upload-time = "2026-04-22T03:14:34.691Z" }, + { url = "https://files.pythonhosted.org/packages/c5/40/f92c8cdc9595bf24c7e483a3156bfe0cc99a5cf5545d8dba8e7fe000c10b/mlx-0.31.2-cp311-cp311-manylinux_2_35_x86_64.whl", hash = "sha256:b368f7ede4238cc44076e4843820338c453c21ee50bd3ee26d4b182c179fd8e1", size = 692086, upload-time = "2026-04-22T03:14:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/c3/47/5f33906cb03d6a378a697cd2d2641a26b37dea17ee3d9124d7e39e8eca01/mlx-0.31.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:e5067aaf2be1f3d7bba5be52348775804f111173c1ed04639618fd713b1a530f", size = 584863, upload-time = "2026-04-22T03:14:38.211Z" }, + { url = "https://files.pythonhosted.org/packages/08/e7/a851a451b1327af9fb4df3991b9ae87d066b6f6630e854af55c288b0995a/mlx-0.31.2-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:edb9797db7d852477ca1c99708058654ee860d4148fe5765f0d55528e2b1aa22", size = 584860, upload-time = "2026-04-22T03:14:39.746Z" }, + { url = "https://files.pythonhosted.org/packages/3b/15/0d1dc0597644e5e7b011ca954ba0c47e13cd880a3b909b0c3f1b4d8bf8f1/mlx-0.31.2-cp312-cp312-macosx_26_0_arm64.whl", hash = "sha256:51ca102db641b01e7cb083ce8ecb580e281530a141a7ca12544bb370641630ae", size = 584887, upload-time = "2026-04-22T03:14:41.585Z" }, + { url = "https://files.pythonhosted.org/packages/5d/c3/00664239a98e8bd614733c4182cd402d2bacad2d7f79eca66562ac406870/mlx-0.31.2-cp312-cp312-manylinux_2_35_aarch64.whl", hash = "sha256:117c7583cae0ca107cd53c591cc34f8e75f97a505aa47088844b7dc0fc69dc67", size = 627863, upload-time = "2026-04-22T03:14:43.326Z" }, + { url = "https://files.pythonhosted.org/packages/53/7b/af6cd73a79772af6f19eab2cb4c48eda23a9294d1650a4c1269a9996e532/mlx-0.31.2-cp312-cp312-manylinux_2_35_x86_64.whl", hash = "sha256:99572133181481640a8bf8d449daf083816d0af3ee050c8adfc5bf45ceca91c6", size = 685090, upload-time = "2026-04-22T03:14:45.058Z" }, + { url = "https://files.pythonhosted.org/packages/a3/3f/888f8664d4f8e23a1363a5f50024be5216e199ab7ad0ba20988c7ed6d729/mlx-0.31.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:1b3fb0dda955b0d552ce57bdd6f42b3309ab21b067e40587d6848443d307e91f", size = 584796, upload-time = "2026-04-22T03:14:47.215Z" }, + { url = "https://files.pythonhosted.org/packages/dd/14/e9cd18b51f9e1dbcb060eec0fafc2d2428c8e1eacd9b0a02d7c5ce75b661/mlx-0.31.2-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:34b0171cd9eb5c43fdd82091f6135d6ccc5a065363a4a3e68fac64fb4e53d37c", size = 584790, upload-time = "2026-04-22T03:14:48.519Z" }, + { url = "https://files.pythonhosted.org/packages/ca/20/c6c5fb998c7834d094b2bfb9f003b5246cb270f0266da055c55546c34999/mlx-0.31.2-cp313-cp313-macosx_26_0_arm64.whl", hash = "sha256:c05981684279a8935d58b0dde3ea5b02d210c3bad3319aa0e9934ec2df165752", size = 584795, upload-time = "2026-04-22T03:14:49.904Z" }, + { url = "https://files.pythonhosted.org/packages/0b/19/aca251d4c5f3532ce9c2c1e95ad76740d9c6c298f406f62d992f465b9be0/mlx-0.31.2-cp313-cp313-manylinux_2_35_aarch64.whl", hash = "sha256:cd1f4189e5f1bc68735f44eb63ce98ae09d66ac75d7ab5b15a41afae7e9f0513", size = 627843, upload-time = "2026-04-22T03:14:51.351Z" }, + { url = "https://files.pythonhosted.org/packages/3e/2b/b89364883b98f21c2fe29e52d4ac8bc2fa2fe0d79293b36ec421efc1854a/mlx-0.31.2-cp313-cp313-manylinux_2_35_x86_64.whl", hash = "sha256:53c8d57ffa9ce77f8355663be05014c0dd37280e57f19126fb0a24389a30684b", size = 685064, upload-time = "2026-04-22T03:14:52.75Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f5/e63f6a9316ded2d14a8ebc7a9ca25734c784e8c54d064a78b4dceeacec0e/mlx-0.31.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:a13c9ce23c3deef6aa5a09315e7953e1a5dc311e851fa16fc74c81fb2509c0b9", size = 588417, upload-time = "2026-04-22T03:14:54.094Z" }, + { url = "https://files.pythonhosted.org/packages/31/50/9d0c03ea3134cd85c132df7b0e4b75e6344bd8b4881a0b9c465cfa27f724/mlx-0.31.2-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:b0764bf11fc3a71dee988e19275eef67775cab63112d8bb7ef173ca8b2a1247c", size = 588421, upload-time = "2026-04-22T03:14:55.898Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5b/d364cc793bcb504621313acb55627cf0d5403ab2e0a594aa081cdbe4591f/mlx-0.31.2-cp314-cp314-macosx_26_0_arm64.whl", hash = "sha256:59ccbd0f0044d4f97f11ebcbf0c480bc9e962935fd96275f120954afea65be8a", size = 588384, upload-time = "2026-04-22T03:14:57.439Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a4/e822202dd2e4e7d08671f2ecf7b6500af74f5bad5ceb27086b1aa6902f3a/mlx-0.31.2-cp314-cp314-manylinux_2_35_aarch64.whl", hash = "sha256:e81798c610f95a09c642c89214ba5c23b72ce18ce4728184aceabe7eddca33d7", size = 630473, upload-time = "2026-04-22T03:14:58.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/6f/da48d2d7a76e644d35438ef6f33c68755fdd382e2c546fd1804ccba01d04/mlx-0.31.2-cp314-cp314-manylinux_2_35_x86_64.whl", hash = "sha256:69fbc94bf53607a75af9eb3e22c354738a6fe4e25aa4e2b20934b009a4bba1f3", size = 685459, upload-time = "2026-04-22T03:15:00.45Z" }, +] + +[[package]] +name = "mlx-lm" +version = "0.31.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "mlx", marker = "sys_platform == 'darwin'" }, + { name = "numpy" }, + { name = "protobuf" }, + { name = "pyyaml" }, + { name = "sentencepiece" }, + { name = "transformers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/94/9a38d6b0c6fcca995b9136c94eb7da1e9c5165652edf228b96b29960fa7a/mlx_lm-0.31.3.tar.gz", hash = "sha256:61eb0e3ba09444f77f874aff295401d7ccd20b39495cbbce0c782a15474ce733", size = 304318, upload-time = "2026-04-22T07:37:27.922Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/02/9a67b8e4f87e3e2e5cd7b1ad79304b93c09a0db6af34bee75e6551c06c60/mlx_lm-0.31.3-py3-none-any.whl", hash = "sha256:758cfddf1180053b7613db76fad3d246a331a2a905808e1164a275621fc983b8", size = 408890, upload-time = "2026-04-22T07:37:25.965Z" }, ] [[package]] name = "mlx-metal" -version = "0.30.0" +version = "0.31.2" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/9f/47ebb6e9b2c33371c6ca3733e70324ed064f49e790ee4e194b713d6d7d84/mlx_metal-0.30.0-py3-none-macosx_14_0_arm64.whl", hash = "sha256:f48543b10d13bf0591b3f99c6eb585dd2c2e5db379edae5df0f19a728cb41742", size = 36819863, upload-time = "2025-11-20T06:42:23.927Z" }, - { url = "https://files.pythonhosted.org/packages/7f/91/c04e420326390c37d4c13f58960956d698cd34c7432ae3860bc5c6be71a0/mlx_metal-0.30.0-py3-none-macosx_15_0_arm64.whl", hash = "sha256:74bb5d10e0f24e21973d39430557bbd5d733c2a6599c3f1b87f9a0ff73fed2c8", size = 36817633, upload-time = "2025-11-20T01:16:27.77Z" }, - { url = "https://files.pythonhosted.org/packages/86/18/7af11eac0f488b68c436d249ffcf4f76003326e691994db3f720d21f21bb/mlx_metal-0.30.0-py3-none-macosx_26_0_arm64.whl", hash = "sha256:310fda8b2f7345865f3dd75a9b478e974f28d22e8ebf05f26af5adc0e8979cee", size = 44878409, upload-time = "2025-11-20T05:33:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/3f/69/fe3b783ebe999f3118234e1e940feb622518bfb1dea6ac5d13b1d36a8449/mlx_metal-0.31.2-py3-none-macosx_14_0_arm64.whl", hash = "sha256:b25385bcee18fc194092255b8b53b9a3d8489eb650e59160f1b57aadd07aa2dc", size = 40055588, upload-time = "2026-04-22T03:14:14.43Z" }, + { url = "https://files.pythonhosted.org/packages/4f/5d/4c690d5b93c30ba002656c37363159d978705bf8eb801b8481840fb942c2/mlx_metal-0.31.2-py3-none-macosx_15_0_arm64.whl", hash = "sha256:e9d4e5fce6ca10a87a0e388597f99519ad594d09e674708b5312bd8bd4f5997d", size = 40053220, upload-time = "2026-04-22T03:14:18.048Z" }, + { url = "https://files.pythonhosted.org/packages/99/82/11fd62a8d7a3e96e5c43220b17de0151e3f10101f8bb3b865f5bd9cdd074/mlx_metal-0.31.2-py3-none-macosx_26_0_arm64.whl", hash = "sha256:84ffb60ee503f03eb684f5fb168d5cff31e2a16b7f27c1731eaf7662bd6e9b46", size = 55792151, upload-time = "2026-04-22T03:14:22.059Z" }, ] [[package]] @@ -1358,6 +1379,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/2d/d4bf65e47cea8ff2c794a600c4fd1273a7902f268757c531e0ee9f18aa58/pooch-1.9.0-py3-none-any.whl", hash = "sha256:f265597baa9f760d25ceb29d0beb8186c243d6607b0f60b83ecf14078dbc703b", size = 67175, upload-time = "2026-01-30T19:15:08.36Z" }, ] +[[package]] +name = "protobuf" +version = "7.35.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" }, + { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" }, + { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, +] + [[package]] name = "ptyprocess" version = "0.7.0" @@ -1720,6 +1756,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6d/63/8b41cea3afd7f58eb64ac9251668ee0073789a3bc9ac6f816c8c6fef986d/ruff-0.14.8-py3-none-win_arm64.whl", hash = "sha256:965a582c93c63fe715fd3e3f8aa37c4b776777203d8e1d8aa3cc0c14424a4b99", size = 13634522, upload-time = "2025-12-04T15:06:43.212Z" }, ] +[[package]] +name = "safetensors" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/06/f955dbbb1859e3bd23c8ac6141af5106e7ad5fedec4a3a6e3d60f94b7001/safetensors-0.8.0.tar.gz", hash = "sha256:fabaf3e0f18a6618d9b36560682562157f77c2b71fcffc7b432be2baed9d753d", size = 325846, upload-time = "2026-06-09T07:52:25.563Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/a0/f718cda65b05407d228f97602cf60dca269c979867aa5beb25410de26cd3/safetensors-0.8.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c554f85858e05226d3c2828e32395e677434685d6d94594a41643361c5e837f0", size = 473568, upload-time = "2026-06-09T07:52:18.829Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b1/fa7c600e7dceae12e9606c7578cbc9ff1e1ed55844883ee5c92205e86226/safetensors-0.8.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c80201d22cbf405b80647a60ada77bba06c8fba2da2743ba1e89cdcc39a81f25", size = 484562, upload-time = "2026-06-09T07:52:17.518Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/65a7de0af421317bb36a067241e4235fff194eed60b961ed6d3f59a3fc60/safetensors-0.8.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a46e5ff292c356d6991e60942ba7f79817682d3a2cef0702136448cb9c4d235", size = 502844, upload-time = "2026-06-09T07:52:07.624Z" }, + { url = "https://files.pythonhosted.org/packages/91/4f/3175c9d75634e0e0dda0082794193521035edd7c70a6f212bf33ca06ddf4/safetensors-0.8.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4124502b78f03534117c848f87a39b8f31e577b15eff423bf8bfb95f2a8c30d0", size = 511823, upload-time = "2026-06-09T07:52:09.565Z" }, + { url = "https://files.pythonhosted.org/packages/20/87/846c289e7aa2299eff406335717cf43ce8777194ece8aad75772e0411615/safetensors-0.8.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7bc0a787ba8a35be368ee3574edfa2b1ad389eebd0a72e482ae275490e3f6c98", size = 633461, upload-time = "2026-06-09T07:52:11.128Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/8d64d9df2c45d5ded401df889d0ad90882804ca172d79ec4f0df8f727fe0/safetensors-0.8.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:040070828e36dc8e122178bbbd5830ff9e97920affb84cbe0f46442497bed358", size = 545148, upload-time = "2026-06-09T07:52:13.603Z" }, + { url = "https://files.pythonhosted.org/packages/28/50/f203ff3a3ddfe19308efc83c5a3a29ed02bf786732ec35e68bf9162f3365/safetensors-0.8.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd6f3f93c9a0a7cc2788ee63fb763353d4bd2e89b0751bc78fcf7dda00bea774", size = 516040, upload-time = "2026-06-09T07:52:16.29Z" }, + { url = "https://files.pythonhosted.org/packages/46/fb/cdaed17ceb2948784fd9c36b6fd3e951b608547cea81a48e8ee6f8cfdfcb/safetensors-0.8.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:fcdd41ec4628fee5799f807c73c353629130fbd942aa23d83c623dd6c9d52d78", size = 513832, upload-time = "2026-06-09T07:52:12.37Z" }, + { url = "https://files.pythonhosted.org/packages/0d/49/1e15de264dcc3b77943d2d0c56a95809956883b1c2d6d585c792523f180b/safetensors-0.8.0-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8e9f537aa183a38ace122d27303dcd986b26bd2a7591f9181d7f0c396f4677ca", size = 559930, upload-time = "2026-06-09T07:52:14.743Z" }, + { url = "https://files.pythonhosted.org/packages/2a/43/bf38443278eab4b1be1fce2931e2b012ad9cb7df52ada751d0aab8f7659a/safetensors-0.8.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:87eec7ffed2b809f05a398a8becb7d013f19f7837cd15d9748580d6cf30dbaf4", size = 678670, upload-time = "2026-06-09T07:52:20.032Z" }, + { url = "https://files.pythonhosted.org/packages/72/e3/68cd3fa5b48488e84add63e04cb12f3bc28ae4638c06d4508c6e88823d0e/safetensors-0.8.0-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:4a95ae2b05d7726d751da4ebf626a2ca782b706e101bd894c95bc2450b1cffcc", size = 786679, upload-time = "2026-06-09T07:52:21.322Z" }, + { url = "https://files.pythonhosted.org/packages/29/4b/1c19c509d56e01f4fbb3d0a2e597450f6cc04d1d56cf52defb0a62dfd715/safetensors-0.8.0-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae091f16662658bdc019a4ff6cb4c085bb7d725eb5978b183ffd265863b6d2d", size = 765683, upload-time = "2026-06-09T07:52:22.594Z" }, + { url = "https://files.pythonhosted.org/packages/27/43/41c1621732edd934d868a00d1b891584c892a7b62a9aab82ea5a0a5623ee/safetensors-0.8.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8e080062fcde23be189565e1c3305d16751a218ecf9412c8601e64204eb6f846", size = 722361, upload-time = "2026-06-09T07:52:23.924Z" }, + { url = "https://files.pythonhosted.org/packages/8e/3f/73ccf82579412b4a71c4ca673f10b5f1f888d7cf5af7fe24f27d30307be4/safetensors-0.8.0-cp310-abi3-win32.whl", hash = "sha256:2ddf52eac562eda224f99acfa7889d02968c1fd59a5b011ae7d8137c37e9c02d", size = 342401, upload-time = "2026-06-09T07:52:28.895Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6d/3fba214c1e5e0f69991677ec3bc17023f0421776975e1de0c682dca475e2/safetensors-0.8.0-cp310-abi3-win_amd64.whl", hash = "sha256:096ec1a98435df7beb08853bb5aa9081a84f23d0adc67ed1a0a10550f608373f", size = 355540, upload-time = "2026-06-09T07:52:27.832Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fc/7eedc3510d97878876e32774eebbeb61c43f148a96e915c84229a3e967aa/safetensors-0.8.0-cp310-abi3-win_arm64.whl", hash = "sha256:f7838e5135a406ad3e02efdcb8cf2e5397d368b0154537c4fec682dbc544d452", size = 340500, upload-time = "2026-06-09T07:52:26.745Z" }, +] + [[package]] name = "scikit-learn" version = "1.9.0" @@ -1849,6 +1909,62 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, ] +[[package]] +name = "sentencepiece" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/15/2e7a025fc62d764b151ae6d0f2a92f8081755ebe8d4a64099accc6f77ba6/sentencepiece-0.2.1.tar.gz", hash = "sha256:8138cec27c2f2282f4a34d9a016e3374cd40e5c6e9cb335063db66a0a3b71fad", size = 3228515, upload-time = "2025-08-12T07:00:51.718Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/15/46afbab00733d81788b64be430ca1b93011bb9388527958e26cc31832de5/sentencepiece-0.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6356d0986b8b8dc351b943150fcd81a1c6e6e4d439772e8584c64230e58ca987", size = 1942560, upload-time = "2025-08-12T06:59:25.82Z" }, + { url = "https://files.pythonhosted.org/packages/fa/79/7c01b8ef98a0567e9d84a4e7a910f8e7074fcbf398a5cd76f93f4b9316f9/sentencepiece-0.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8f8ba89a3acb3dc1ae90f65ec1894b0b9596fdb98ab003ff38e058f898b39bc7", size = 1325385, upload-time = "2025-08-12T06:59:27.722Z" }, + { url = "https://files.pythonhosted.org/packages/bb/88/2b41e07bd24f33dcf2f18ec3b74247aa4af3526bad8907b8727ea3caba03/sentencepiece-0.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:02593eca45440ef39247cee8c47322a34bdcc1d8ae83ad28ba5a899a2cf8d79a", size = 1253319, upload-time = "2025-08-12T06:59:29.306Z" }, + { url = "https://files.pythonhosted.org/packages/a0/54/38a1af0c6210a3c6f95aa46d23d6640636d020fba7135cd0d9a84ada05a7/sentencepiece-0.2.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a0d15781a171d188b661ae4bde1d998c303f6bd8621498c50c671bd45a4798e", size = 1316162, upload-time = "2025-08-12T06:59:30.914Z" }, + { url = "https://files.pythonhosted.org/packages/ef/66/fb191403ade791ad2c3c1e72fe8413e63781b08cfa3aa4c9dfc536d6e795/sentencepiece-0.2.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f5a3e0d9f445ed9d66c0fec47d4b23d12cfc858b407a03c194c1b26c2ac2a63", size = 1387785, upload-time = "2025-08-12T06:59:32.491Z" }, + { url = "https://files.pythonhosted.org/packages/a9/2d/3bd9b08e70067b2124518b308db6a84a4f8901cc8a4317e2e4288cdd9b4d/sentencepiece-0.2.1-cp311-cp311-win32.whl", hash = "sha256:6d297a1748d429ba8534eebe5535448d78b8acc32d00a29b49acf28102eeb094", size = 999555, upload-time = "2025-08-12T06:59:34.475Z" }, + { url = "https://files.pythonhosted.org/packages/32/b8/f709977f5fda195ae1ea24f24e7c581163b6f142b1005bc3d0bbfe4d7082/sentencepiece-0.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:82d9ead6591015f009cb1be1cb1c015d5e6f04046dbb8c9588b931e869a29728", size = 1054617, upload-time = "2025-08-12T06:59:36.461Z" }, + { url = "https://files.pythonhosted.org/packages/7a/40/a1fc23be23067da0f703709797b464e8a30a1c78cc8a687120cd58d4d509/sentencepiece-0.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:39f8651bd10974eafb9834ce30d9bcf5b73e1fc798a7f7d2528f9820ca86e119", size = 1033877, upload-time = "2025-08-12T06:59:38.391Z" }, + { url = "https://files.pythonhosted.org/packages/4a/be/32ce495aa1d0e0c323dcb1ba87096037358edee539cac5baf8755a6bd396/sentencepiece-0.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:57cae326c8727de58c85977b175af132a7138d84c764635d7e71bbee7e774133", size = 1943152, upload-time = "2025-08-12T06:59:40.048Z" }, + { url = "https://files.pythonhosted.org/packages/88/7e/ff23008899a58678e98c6ff592bf4d368eee5a71af96d0df6b38a039dd4f/sentencepiece-0.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:56dd39a3c4d6493db3cdca7e8cc68c6b633f0d4195495cbadfcf5af8a22d05a6", size = 1325651, upload-time = "2025-08-12T06:59:41.536Z" }, + { url = "https://files.pythonhosted.org/packages/19/84/42eb3ce4796777a1b5d3699dfd4dca85113e68b637f194a6c8d786f16a04/sentencepiece-0.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d9381351182ff9888cc80e41c632e7e274b106f450de33d67a9e8f6043da6f76", size = 1253645, upload-time = "2025-08-12T06:59:42.903Z" }, + { url = "https://files.pythonhosted.org/packages/89/fa/d3d5ebcba3cb9e6d3775a096251860c41a6bc53a1b9461151df83fe93255/sentencepiece-0.2.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99f955df238021bf11f0fc37cdb54fd5e5b5f7fd30ecc3d93fb48b6815437167", size = 1316273, upload-time = "2025-08-12T06:59:44.476Z" }, + { url = "https://files.pythonhosted.org/packages/04/88/14f2f4a2b922d8b39be45bf63d79e6cd3a9b2f248b2fcb98a69b12af12f5/sentencepiece-0.2.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0cdfecef430d985f1c2bcbfff3defd1d95dae876fbd0173376012d2d7d24044b", size = 1387881, upload-time = "2025-08-12T06:59:46.09Z" }, + { url = "https://files.pythonhosted.org/packages/fd/b8/903e5ccb77b4ef140605d5d71b4f9e0ad95d456d6184688073ed11712809/sentencepiece-0.2.1-cp312-cp312-win32.whl", hash = "sha256:a483fd29a34c3e34c39ac5556b0a90942bec253d260235729e50976f5dba1068", size = 999540, upload-time = "2025-08-12T06:59:48.023Z" }, + { url = "https://files.pythonhosted.org/packages/2d/81/92df5673c067148c2545b1bfe49adfd775bcc3a169a047f5a0e6575ddaca/sentencepiece-0.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:4cdc7c36234fda305e85c32949c5211faaf8dd886096c7cea289ddc12a2d02de", size = 1054671, upload-time = "2025-08-12T06:59:49.895Z" }, + { url = "https://files.pythonhosted.org/packages/fe/02/c5e3bc518655d714622bec87d83db9cdba1cd0619a4a04e2109751c4f47f/sentencepiece-0.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:daeb5e9e9fcad012324807856113708614d534f596d5008638eb9b40112cd9e4", size = 1033923, upload-time = "2025-08-12T06:59:51.952Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4a/85fbe1706d4d04a7e826b53f327c4b80f849cf1c7b7c5e31a20a97d8f28b/sentencepiece-0.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dcd8161eee7b41aae57ded06272905dbd680a0a04b91edd0f64790c796b2f706", size = 1943150, upload-time = "2025-08-12T06:59:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/c2/83/4cfb393e287509fc2155480b9d184706ef8d9fa8cbf5505d02a5792bf220/sentencepiece-0.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c6c8f42949f419ff8c7e9960dbadcfbc982d7b5efc2f6748210d3dd53a7de062", size = 1325651, upload-time = "2025-08-12T06:59:55.073Z" }, + { url = "https://files.pythonhosted.org/packages/8d/de/5a007fb53b1ab0aafc69d11a5a3dd72a289d5a3e78dcf2c3a3d9b14ffe93/sentencepiece-0.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:097f3394e99456e9e4efba1737c3749d7e23563dd1588ce71a3d007f25475fff", size = 1253641, upload-time = "2025-08-12T06:59:56.562Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d2/f552be5928105588f4f4d66ee37dd4c61460d8097e62d0e2e0eec41bc61d/sentencepiece-0.2.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d7b670879c370d350557edabadbad1f6561a9e6968126e6debca4029e5547820", size = 1316271, upload-time = "2025-08-12T06:59:58.109Z" }, + { url = "https://files.pythonhosted.org/packages/96/df/0cfe748ace5485be740fed9476dee7877f109da32ed0d280312c94ec259f/sentencepiece-0.2.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7f0fd2f2693309e6628aeeb2e2faf6edd221134dfccac3308ca0de01f8dab47", size = 1387882, upload-time = "2025-08-12T07:00:00.701Z" }, + { url = "https://files.pythonhosted.org/packages/ac/dd/f7774d42a881ced8e1739f393ab1e82ece39fc9abd4779e28050c2e975b5/sentencepiece-0.2.1-cp313-cp313-win32.whl", hash = "sha256:92b3816aa2339355fda2c8c4e021a5de92180b00aaccaf5e2808972e77a4b22f", size = 999541, upload-time = "2025-08-12T07:00:02.709Z" }, + { url = "https://files.pythonhosted.org/packages/dd/e9/932b9eae6fd7019548321eee1ab8d5e3b3d1294df9d9a0c9ac517c7b636d/sentencepiece-0.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:10ed3dab2044c47f7a2e7b4969b0c430420cdd45735d78c8f853191fa0e3148b", size = 1054669, upload-time = "2025-08-12T07:00:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/c9/3a/76488a00ea7d6931689cda28726a1447d66bf1a4837943489314593d5596/sentencepiece-0.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac650534e2251083c5f75dde4ff28896ce7c8904133dc8fef42780f4d5588fcd", size = 1033922, upload-time = "2025-08-12T07:00:06.496Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b6/08fe2ce819e02ccb0296f4843e3f195764ce9829cbda61b7513f29b95718/sentencepiece-0.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:8dd4b477a7b069648d19363aad0cab9bad2f4e83b2d179be668efa672500dc94", size = 1946052, upload-time = "2025-08-12T07:00:08.136Z" }, + { url = "https://files.pythonhosted.org/packages/ab/d9/1ea0e740591ff4c6fc2b6eb1d7510d02f3fb885093f19b2f3abd1363b402/sentencepiece-0.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0c0f672da370cc490e4c59d89e12289778310a0e71d176c541e4834759e1ae07", size = 1327408, upload-time = "2025-08-12T07:00:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/99/7e/1fb26e8a21613f6200e1ab88824d5d203714162cf2883248b517deb500b7/sentencepiece-0.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ad8493bea8432dae8d6830365352350f3b4144415a1d09c4c8cb8d30cf3b6c3c", size = 1254857, upload-time = "2025-08-12T07:00:11.021Z" }, + { url = "https://files.pythonhosted.org/packages/bc/85/c72fd1f3c7a6010544d6ae07f8ddb38b5e2a7e33bd4318f87266c0bbafbf/sentencepiece-0.2.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b81a24733726e3678d2db63619acc5a8dccd074f7aa7a54ecd5ca33ca6d2d596", size = 1315722, upload-time = "2025-08-12T07:00:12.989Z" }, + { url = "https://files.pythonhosted.org/packages/4a/e8/661e5bd82a8aa641fd6c1020bd0e890ef73230a2b7215ddf9c8cd8e941c2/sentencepiece-0.2.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0a81799d0a68d618e89063fb423c3001a034c893069135ffe51fee439ae474d6", size = 1387452, upload-time = "2025-08-12T07:00:15.088Z" }, + { url = "https://files.pythonhosted.org/packages/99/5e/ae66c361023a470afcbc1fbb8da722c72ea678a2fcd9a18f1a12598c7501/sentencepiece-0.2.1-cp313-cp313t-win32.whl", hash = "sha256:89a3ea015517c42c0341d0d962f3e6aaf2cf10d71b1932d475c44ba48d00aa2b", size = 1002501, upload-time = "2025-08-12T07:00:16.966Z" }, + { url = "https://files.pythonhosted.org/packages/c1/03/d332828c4ff764e16c1b56c2c8f9a33488bbe796b53fb6b9c4205ddbf167/sentencepiece-0.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:33f068c9382dc2e7c228eedfd8163b52baa86bb92f50d0488bf2b7da7032e484", size = 1057555, upload-time = "2025-08-12T07:00:18.573Z" }, + { url = "https://files.pythonhosted.org/packages/88/14/5aee0bf0864df9bd82bd59e7711362908e4935e3f9cdc1f57246b5d5c9b9/sentencepiece-0.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:b3616ad246f360e52c85781e47682d31abfb6554c779e42b65333d4b5f44ecc0", size = 1036042, upload-time = "2025-08-12T07:00:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/24/9c/89eb8b2052f720a612478baf11c8227dcf1dc28cd4ea4c0c19506b5af2a2/sentencepiece-0.2.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:5d0350b686c320068702116276cfb26c066dc7e65cfef173980b11bb4d606719", size = 1943147, upload-time = "2025-08-12T07:00:21.809Z" }, + { url = "https://files.pythonhosted.org/packages/82/0b/a1432bc87f97c2ace36386ca23e8bd3b91fb40581b5e6148d24b24186419/sentencepiece-0.2.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c7f54a31cde6fa5cb030370566f68152a742f433f8d2be458463d06c208aef33", size = 1325624, upload-time = "2025-08-12T07:00:23.289Z" }, + { url = "https://files.pythonhosted.org/packages/ea/99/bbe054ebb5a5039457c590e0a4156ed073fb0fe9ce4f7523404dd5b37463/sentencepiece-0.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c83b85ab2d6576607f31df77ff86f28182be4a8de6d175d2c33ca609925f5da1", size = 1253670, upload-time = "2025-08-12T07:00:24.69Z" }, + { url = "https://files.pythonhosted.org/packages/19/ad/d5c7075f701bd97971d7c2ac2904f227566f51ef0838dfbdfdccb58cd212/sentencepiece-0.2.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1855f57db07b51fb51ed6c9c452f570624d2b169b36f0f79ef71a6e6c618cd8b", size = 1316247, upload-time = "2025-08-12T07:00:26.435Z" }, + { url = "https://files.pythonhosted.org/packages/fb/03/35fbe5f3d9a7435eebd0b473e09584bd3cc354ce118b960445b060d33781/sentencepiece-0.2.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01e6912125cb45d3792f530a4d38f8e21bf884d6b4d4ade1b2de5cf7a8d2a52b", size = 1387894, upload-time = "2025-08-12T07:00:28.339Z" }, + { url = "https://files.pythonhosted.org/packages/dc/aa/956ef729aafb6c8f9c443104c9636489093bb5c61d6b90fc27aa1a865574/sentencepiece-0.2.1-cp314-cp314-win32.whl", hash = "sha256:c415c9de1447e0a74ae3fdb2e52f967cb544113a3a5ce3a194df185cbc1f962f", size = 1096698, upload-time = "2025-08-12T07:00:29.764Z" }, + { url = "https://files.pythonhosted.org/packages/b8/cb/fe400d8836952cc535c81a0ce47dc6875160e5fedb71d2d9ff0e9894c2a6/sentencepiece-0.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:881b2e44b14fc19feade3cbed314be37de639fc415375cefaa5bc81a4be137fd", size = 1155115, upload-time = "2025-08-12T07:00:32.865Z" }, + { url = "https://files.pythonhosted.org/packages/32/89/047921cf70f36c7b6b6390876b2399b3633ab73b8d0cb857e5a964238941/sentencepiece-0.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:2005242a16d2dc3ac5fe18aa7667549134d37854823df4c4db244752453b78a8", size = 1133890, upload-time = "2025-08-12T07:00:34.763Z" }, + { url = "https://files.pythonhosted.org/packages/a1/11/5b414b9fae6255b5fb1e22e2ed3dc3a72d3a694e5703910e640ac78346bb/sentencepiece-0.2.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:a19adcec27c524cb7069a1c741060add95f942d1cbf7ad0d104dffa0a7d28a2b", size = 1946081, upload-time = "2025-08-12T07:00:36.97Z" }, + { url = "https://files.pythonhosted.org/packages/77/eb/7a5682bb25824db8545f8e5662e7f3e32d72a508fdce086029d89695106b/sentencepiece-0.2.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:e37e4b4c4a11662b5db521def4e44d4d30ae69a1743241412a93ae40fdcab4bb", size = 1327406, upload-time = "2025-08-12T07:00:38.669Z" }, + { url = "https://files.pythonhosted.org/packages/03/b0/811dae8fb9f2784e138785d481469788f2e0d0c109c5737372454415f55f/sentencepiece-0.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:477c81505db072b3ab627e7eab972ea1025331bd3a92bacbf798df2b75ea86ec", size = 1254846, upload-time = "2025-08-12T07:00:40.611Z" }, + { url = "https://files.pythonhosted.org/packages/ef/23/195b2e7ec85ebb6a547969f60b723c7aca5a75800ece6cc3f41da872d14e/sentencepiece-0.2.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:010f025a544ef770bb395091d57cb94deb9652d8972e0d09f71d85d5a0816c8c", size = 1315721, upload-time = "2025-08-12T07:00:42.914Z" }, + { url = "https://files.pythonhosted.org/packages/7e/aa/553dbe4178b5f23eb28e59393dddd64186178b56b81d9b8d5c3ff1c28395/sentencepiece-0.2.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:733e59ff1794d26db706cd41fc2d7ca5f6c64a820709cb801dc0ea31780d64ab", size = 1387458, upload-time = "2025-08-12T07:00:44.56Z" }, + { url = "https://files.pythonhosted.org/packages/66/7c/08ff0012507297a4dd74a5420fdc0eb9e3e80f4e88cab1538d7f28db303d/sentencepiece-0.2.1-cp314-cp314t-win32.whl", hash = "sha256:d3233770f78e637dc8b1fda2cd7c3b99ec77e7505041934188a4e7fe751de3b0", size = 1099765, upload-time = "2025-08-12T07:00:46.058Z" }, + { url = "https://files.pythonhosted.org/packages/91/d5/2a69e1ce15881beb9ddfc7e3f998322f5cedcd5e4d244cb74dade9441663/sentencepiece-0.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e4366c97b68218fd30ea72d70c525e6e78a6c0a88650f57ac4c43c63b234a9d", size = 1157807, upload-time = "2025-08-12T07:00:47.673Z" }, + { url = "https://files.pythonhosted.org/packages/f3/16/54f611fcfc2d1c46cbe3ec4169780b2cfa7cf63708ef2b71611136db7513/sentencepiece-0.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:105e36e75cbac1292642045458e8da677b2342dcd33df503e640f0b457cb6751", size = 1136264, upload-time = "2025-08-12T07:00:49.485Z" }, +] + [[package]] name = "setuptools" version = "80.9.0" @@ -2047,6 +2163,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/df/c7891ef9d2712ad774777271d39fdef63941ffba0a9d59b7ad1fd2765e57/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71", size = 920667, upload-time = "2025-10-06T20:22:34.444Z" }, ] +[[package]] +name = "tokenizers" +version = "0.22.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115, upload-time = "2026-01-05T10:45:15.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275, upload-time = "2026-01-05T10:41:02.158Z" }, + { url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472, upload-time = "2026-01-05T10:41:00.276Z" }, + { url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736, upload-time = "2026-01-05T10:40:32.165Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835, upload-time = "2026-01-05T10:40:38.847Z" }, + { url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673, upload-time = "2026-01-05T10:40:56.614Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818, upload-time = "2026-01-05T10:40:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195, upload-time = "2026-01-05T10:40:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982, upload-time = "2026-01-05T10:40:58.331Z" }, + { url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245, upload-time = "2026-01-05T10:41:04.053Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069, upload-time = "2026-01-05T10:45:10.673Z" }, + { url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263, upload-time = "2026-01-05T10:45:12.559Z" }, + { url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429, upload-time = "2026-01-05T10:45:14.333Z" }, + { url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363, upload-time = "2026-01-05T10:45:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786, upload-time = "2026-01-05T10:45:18.411Z" }, + { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" }, +] + [[package]] name = "tomli-w" version = "1.2.0" @@ -2133,6 +2275,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, ] +[[package]] +name = "transformers" +version = "5.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "regex" }, + { name = "safetensors" }, + { name = "tokenizers" }, + { name = "tqdm" }, + { name = "typer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/7c/8240f612819718100a9346dc28dea6a11370c3ca9c8c6eabadd3dea4ef29/transformers-5.12.1.tar.gz", hash = "sha256:679ee731c8225347889ad4fb3b2c926a62e9da3b7d284e9d12c791da7272466b", size = 8924054, upload-time = "2026-06-15T17:27:50.604Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/56/bbd60dd8668055803bf8ba55a81f9b8a8b31497f620109a9671d26a2076d/transformers-5.12.1-py3-none-any.whl", hash = "sha256:2a5e109d2021265df7098ffbb738295acaf5ad256f12cbc586db2ea4dcbb1a8a", size = 11150587, upload-time = "2026-06-15T17:27:46.679Z" }, +] + [[package]] name = "triton" version = "3.5.1" @@ -2157,30 +2319,17 @@ wheels = [ [[package]] name = "typer" -version = "0.26.7" +version = "0.25.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, - { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "click" }, { name = "rich" }, { name = "shellingham" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5e/ed/ef06584ccdd5c410df0837951ecd7e15d9a6144ea1bd4c73cecab1a89891/typer-0.26.7.tar.gz", hash = "sha256:e314a34c617e419c091b2830dda3ea1f257134ff593061a8f5b9717ab8dddb3a", size = 201709, upload-time = "2026-06-03T07:18:06.843Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/24/25/2201973529af2c954de0bb725323c3aaed6d7f0ceee8f550dec9185df013/typer-0.26.7-py3-none-any.whl", hash = "sha256:5c87cfbc5d34491c5346ebf49c23e18d56ccb863268d3a8d592b26087c2f5e58", size = 122456, upload-time = "2026-06-03T07:18:05.732Z" }, -] - -[[package]] -name = "typer-slim" -version = "0.20.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8e/45/81b94a52caed434b94da65729c03ad0fb7665fab0f7db9ee54c94e541403/typer_slim-0.20.0.tar.gz", hash = "sha256:9fc6607b3c6c20f5c33ea9590cbeb17848667c51feee27d9e314a579ab07d1a3", size = 106561, upload-time = "2025-10-20T17:03:46.642Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/51/9aed62104cea109b820bbd6c14245af756112017d309da813ef107d42e7e/typer-0.25.1.tar.gz", hash = "sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc", size = 122276, upload-time = "2026-04-30T19:32:16.964Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/dd/5cbf31f402f1cc0ab087c94d4669cfa55bd1e818688b910631e131d74e75/typer_slim-0.20.0-py3-none-any.whl", hash = "sha256:f42a9b7571a12b97dddf364745d29f12221865acef7a2680065f9bb29c7dc89d", size = 47087, upload-time = "2025-10-20T17:03:44.546Z" }, + { url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" }, ] [[package]] @@ -2267,13 +2416,16 @@ dependencies = [ [package.optional-dependencies] all = [ + { name = "mlx-lm" }, { name = "mlx-whisper" }, { name = "parakeet-mlx" }, ] parakeet = [ + { name = "mlx-lm" }, { name = "parakeet-mlx" }, ] whisper = [ + { name = "mlx-lm" }, { name = "mlx-whisper" }, ] @@ -2286,6 +2438,9 @@ dev = [ [package.metadata] requires-dist = [ { name = "loguru" }, + { name = "mlx-lm", marker = "extra == 'all'" }, + { name = "mlx-lm", marker = "extra == 'parakeet'" }, + { name = "mlx-lm", marker = "extra == 'whisper'" }, { name = "mlx-whisper", marker = "extra == 'all'" }, { name = "mlx-whisper", marker = "extra == 'whisper'" }, { name = "numpy" }, diff --git a/v2t/app.py b/v2t/app.py index 818cfc8..f7b7be2 100644 --- a/v2t/app.py +++ b/v2t/app.py @@ -68,7 +68,8 @@ class VoiceToText: def __init__(self, cfg: Config): self.cfg = cfg self.stt_model = cfg.stt_model or backends.STT[cfg.backend].default_model - self.stt = None # loaded in run() + self.stt = None # loaded in run() + self.cleaner = None # loaded in run() if cleanup is enabled self.recording = False self.processing = False self.frames: list[np.ndarray] = [] @@ -147,12 +148,10 @@ def process_audio(self, audio_s: float): return cleaned_text, cleanup_s = raw_text, 0.0 - if self.cfg.cleanup_enabled: + if self.cleaner is not None: logger.info("Cleaning up...") try: - cleaned_text, _ttft, cleanup_s = backends.cleanup( - raw_text, self.cfg.cleanup_model, self.cfg.mode, self.cfg.ollama_url, - ) + cleaned_text, _ttft, cleanup_s = self.cleaner.cleanup(raw_text, self.cfg.mode) if not cleaned_text: raise RuntimeError("empty response") logger.info(f"Clean: {cleaned_text} ({cleanup_s:.2f}s)") @@ -167,6 +166,8 @@ def process_audio(self, audio_s: float): if self.cfg.save_history: config.append_history({ "audio_s": round(audio_s, 2), "backend": self.cfg.backend, "model": self.stt_model, + "cleanup_engine": self.cfg.cleanup_engine if self.cleaner else None, + "cleanup_model": self.cleaner.model_id if self.cleaner else None, "mode": self.cfg.mode, "stt_s": round(stt_s, 3), "cleanup_s": round(cleanup_s, 3), "raw": raw_text, "clean": cleaned_text, }) @@ -203,9 +204,10 @@ def warmup(self): if self.cfg.cleanup_enabled: t0 = time.perf_counter() + self.cleaner = backends.make_cleanup(self.cfg.cleanup_engine, self.cfg.cleanup_model, self.cfg.ollama_url) try: - backends.cleanup("hi", self.cfg.cleanup_model, self.cfg.mode, self.cfg.ollama_url) - logger.success(f"cleanup model ready ({time.perf_counter()-t0:.1f}s)") + self.cleaner.cleanup("hi", self.cfg.mode) + logger.success(f"cleanup ({self.cfg.cleanup_engine}) ready ({time.perf_counter()-t0:.1f}s)") except Exception as e: logger.warning(f"cleanup warmup failed ({e}); will retry per transcription") diff --git a/v2t/backends.py b/v2t/backends.py index 1f14cb4..0476c23 100644 --- a/v2t/backends.py +++ b/v2t/backends.py @@ -1,14 +1,13 @@ -"""Speech-to-text backends (MLX) and text cleanup (Ollama). +"""Speech-to-text backends and text-cleanup engines, both pluggable. -STT is pluggable so models are easy to switch; Parakeet is the default because -it's the fastest thing on Apple Silicon right now. Heavy MLX imports are lazy so -`v2t config`/`v2t bench --cleanup` work without a GPU model loaded. +STT: parakeet (default) or whisper, both MLX. Cleanup: mlx (in-process via +mlx-lm, default — no daemon) or ollama. Heavy MLX imports are lazy so +`v2t config`/`v2t bench` work without a model loaded. """ from __future__ import annotations import json -import re import subprocess import time import urllib.error @@ -70,58 +69,96 @@ def make_stt(backend: str, model: str = ""): ), } -_THINK = re.compile(r".*?", re.DOTALL) - - -def cleanup(text: str, model: str, mode: str = "strict", url: str = "http://localhost:11434", timeout: int = 60): - """Clean a transcription with a local Ollama model. - - Returns (clean_text, ttft_seconds, total_seconds). Streams over HTTP so we can - time the first token; if the server isn't up, falls back to `ollama run` - (which starts it) and reports ttft=None. blocks are stripped so a - thinking model can't leak reasoning into the output. - """ - prompt = PROMPTS[mode].format(text=text) - t0 = time.perf_counter() - try: - req = urllib.request.Request( - f"{url}/api/generate", - data=json.dumps({"model": model, "prompt": prompt, "stream": True}).encode(), - headers={"Content-Type": "application/json"}, - ) - ttft, parts = None, [] - with urllib.request.urlopen(req, timeout=timeout) as r: - for line in r: - if not line.strip(): - continue - chunk = json.loads(line) - if (piece := chunk.get("response", "")): - if ttft is None: - ttft = time.perf_counter() - t0 - parts.append(piece) - if chunk.get("error"): - raise RuntimeError(chunk["error"]) - if chunk.get("done"): - break - return _THINK.sub("", "".join(parts)).strip(), ttft, time.perf_counter() - t0 - except urllib.error.URLError: - # ponytail: server not reachable; the CLI auto-starts it. No streaming, so no TTFT. - res = subprocess.run(["ollama", "run", model, prompt], capture_output=True, text=True, timeout=timeout) - if res.returncode != 0: - raise RuntimeError(res.stderr.strip() or "ollama run failed") - return _THINK.sub("", res.stdout).strip(), None, time.perf_counter() - t0 +MLX_CLEANUP_DEFAULT = "mlx-community/Qwen3-4B-Instruct-2507-4bit" +OLLAMA_CLEANUP_DEFAULT = "qwen3:4b-instruct-2507" + + +class MLXCleanup: + """In-process cleanup via mlx-lm — no daemon, no HTTP. The default. Pick a + non-thinking instruct model (the default Qwen3-Instruct-2507 doesn't think).""" + + default_model = MLX_CLEANUP_DEFAULT + + def __init__(self, model: str = "", url: str = ""): + try: + from mlx_lm import load, stream_generate + except ImportError as e: + raise SystemExit("mlx cleanup needs mlx-lm — uv tool install 'voice2text[parakeet]' bundles it.") from e + self._stream = stream_generate + self.model_id = model or self.default_model + self.model, self.tokenizer = load(self.model_id) + + def cleanup(self, text: str, mode: str = "strict"): + messages = [{"role": "user", "content": PROMPTS[mode].format(text=text)}] + prompt = self.tokenizer.apply_chat_template(messages, add_generation_prompt=True) + t0, ttft, parts = time.perf_counter(), None, [] + for resp in self._stream(self.model, self.tokenizer, prompt, max_tokens=400): + if ttft is None: + ttft = time.perf_counter() - t0 + parts.append(resp.text) + return "".join(parts).strip(), ttft, time.perf_counter() - t0 + + +class OllamaCleanup: + """Cleanup via a running Ollama server — for people who already use it.""" + + default_model = OLLAMA_CLEANUP_DEFAULT + + def __init__(self, model: str = "", url: str = "http://localhost:11434"): + self.model_id = model or self.default_model + self.url = url + + def cleanup(self, text: str, mode: str = "strict", timeout: int = 60): + prompt = PROMPTS[mode].format(text=text) + t0 = time.perf_counter() + try: + req = urllib.request.Request( + f"{self.url}/api/generate", + data=json.dumps({"model": self.model_id, "prompt": prompt, "stream": True}).encode(), + headers={"Content-Type": "application/json"}, + ) + ttft, parts = None, [] + with urllib.request.urlopen(req, timeout=timeout) as r: + for line in r: + if not line.strip(): + continue + chunk = json.loads(line) + if (piece := chunk.get("response", "")): + if ttft is None: + ttft = time.perf_counter() - t0 + parts.append(piece) + if chunk.get("error"): + raise RuntimeError(chunk["error"]) + if chunk.get("done"): + break + return "".join(parts).strip(), ttft, time.perf_counter() - t0 + except urllib.error.URLError: + # ponytail: server not reachable; the CLI auto-starts it. No streaming, so no TTFT. + res = subprocess.run(["ollama", "run", self.model_id, prompt], capture_output=True, text=True, timeout=timeout) + if res.returncode != 0: + raise RuntimeError(res.stderr.strip() or "ollama run failed") + return res.stdout.strip(), None, time.perf_counter() - t0 + + +CLEANUP = {"mlx": MLXCleanup, "ollama": OllamaCleanup} + + +def make_cleanup(engine: str, model: str = "", url: str = "http://localhost:11434"): + if engine not in CLEANUP: + raise SystemExit(f"unknown cleanup engine {engine!r}; choose: {', '.join(CLEANUP)}") + return CLEANUP[engine](model, url) if __name__ == "__main__": # ponytail: pure-logic checks only; live model calls are covered by `v2t bench`. assert set(STT) == {"parakeet", "whisper"} - assert make_stt.__module__ # importable - assert _THINK.sub("", "nopeHello.").strip() == "Hello." + assert set(CLEANUP) == {"mlx", "ollama"} assert "filler" in PROMPTS["strict"] and "Keep the original phrasing" in PROMPTS["casual"] - try: - make_stt("bogus") - except SystemExit: - pass - else: - raise AssertionError("unknown backend must exit") + for fn in (make_stt, make_cleanup): + try: + fn("bogus") + except SystemExit: + pass + else: + raise AssertionError(f"{fn.__name__} must exit on unknown name") print("backends.py: all checks passed") diff --git a/v2t/bench.py b/v2t/bench.py index 656ad1c..1b5643d 100644 --- a/v2t/bench.py +++ b/v2t/bench.py @@ -1,13 +1,14 @@ -"""Benchmark STT (MLX) and cleanup (Ollama) models -> a markdown grid. +"""Benchmark STT and cleanup models -> a markdown grid. -Each table is models-as-columns. STT needs Apple Silicon; cleanup needs Ollama. -Results are written to benchmarks/results/-.md so you can build a grid -across machines: run on each Mac, commit the file. Absolute times reflect THAT -machine — compare models within a run, not across machines. +Each table is models-as-columns. STT and the default mlx cleanup need Apple Silicon; +the ollama cleanup engine needs Ollama. Results are written to +benchmarks/results/-.md so you can build a grid across machines: run on +each Mac, commit the file. Absolute times reflect THAT machine — compare models +within a run, not across machines. v2t bench # both tables, default models, samples via `say` - v2t bench --cleanup # cleanup table only (runs anywhere Ollama runs) - v2t bench --stt-models parakeet:mlx-community/parakeet-tdt-0.6b-v3 ... + v2t bench --cleanup # cleanup table only + v2t bench --cleanup-models mlx:mlx-community/Qwen3-4B-Instruct-2507-4bit ollama:qwen3:4b-instruct-2507 """ from __future__ import annotations @@ -28,7 +29,10 @@ "parakeet:mlx-community/parakeet-tdt-0.6b-v2", "whisper:mlx-community/whisper-large-v3-turbo", ] -CLEANUP_MODELS = ["qwen3:4b-instruct-2507", "qwen3:1.7b", "qwen2.5:3b"] +CLEANUP_MODELS = [ + "mlx:mlx-community/Qwen3-4B-Instruct-2507-4bit", + "mlx:mlx-community/Qwen2.5-3B-Instruct-4bit", +] # (name, text) — `say` turns these into audio so the STT bench is self-contained. SAY_SAMPLES = [ @@ -109,21 +113,28 @@ def bench_stt(model_specs: list[str], samples, repeat: int) -> dict: return results -def bench_cleanup(models: list[str], samples: list[str], repeat: int, url: str) -> dict: - """{model: {sample_idx: (ttft_s, total_s)}}.""" +def bench_cleanup(specs: list[str], samples: list[str], repeat: int, url: str) -> dict: + """{spec: {sample_idx: (ttft_s, total_s)} | None}. spec is 'engine:model'.""" results = {} - for model in models: - print(f" cleanup {model} ... warming up") - backends.cleanup("hi", model, url=url) # pull/warm - results[model] = {} + for spec in specs: + engine, _, model = spec.partition(":") + print(f" cleanup {spec} ...") + try: + cleaner = backends.make_cleanup(engine, model, url) + cleaner.cleanup("hi") # warm / pull + except Exception as e: # missing model/engine shouldn't abort the whole run + print(f" skipped ({e})") + results[spec] = None + continue + results[spec] = {} for i, sample in enumerate(samples): ttfts, totals = [], [] for _ in range(repeat): - _text, ttft, total = backends.cleanup(sample, model, url=url) + _text, ttft, total = cleaner.cleanup(sample) ttfts.append(ttft if ttft is not None else total) totals.append(total) - results[model][i] = (_median(ttfts), _median(totals)) - print(f" sample {i}: ttft {results[model][i][0]:.2f}s total {results[model][i][1]:.2f}s") + results[spec][i] = (_median(ttfts), _median(totals)) + print(f" sample {i}: ttft {results[spec][i][0]:.2f}s total {results[spec][i][1]:.2f}s") return results @@ -141,18 +152,26 @@ def md_stt_table(results: dict, samples) -> str: def md_cleanup_table(results: dict, samples: list[str]) -> str: - models = list(results) - head = "| sample | " + " | ".join(models) + " |" - sep = "|---|" + "|".join("--:" for _ in models) + "|" - rows = [] - for i in range(len(samples)): - cells = " | ".join(f"{results[m][i][0]:.2f} / {results[m][i][1]:.2f}s" for m in models) - rows.append(f"| {i} | {cells} |") - med = " | ".join( - f"{_median([results[m][i][0] for i in range(len(samples))]):.2f} / " - f"{_median([results[m][i][1] for i in range(len(samples))]):.2f}s" for m in models - ) - rows.append(f"| **median** | {med} |") + specs = list(results) + + def label(s: str) -> str: + engine, _, model = s.partition(":") + return f"{engine}:{model.rsplit('/', 1)[-1]}" + + def cell(spec: str, i: int) -> str: + r = results[spec] + return "n/a" if r is None else f"{r[i][0]:.2f} / {r[i][1]:.2f}s" + + head = "| sample | " + " | ".join(label(s) for s in specs) + " |" + sep = "|---|" + "|".join("--:" for _ in specs) + "|" + rows = [f"| {i} | " + " | ".join(cell(s, i) for s in specs) + " |" for i in range(len(samples))] + med = [] + for s in specs: + r = results[s] + med.append("n/a" if r is None else + f"{_median([r[i][0] for i in range(len(samples))]):.2f} / " + f"{_median([r[i][1] for i in range(len(samples))]):.2f}s") + rows.append("| **median** | " + " | ".join(med) + " |") note = "\n_Cells: time-to-first-token / total. Lower is better._" return "\n".join(["**Text cleanup** — TTFT & total", "", head, sep, *rows]) + "\n" + note @@ -170,7 +189,7 @@ def main(argv: list[str]) -> int: p.add_argument("--stt", action="store_true", help="run the STT table only") p.add_argument("--cleanup", action="store_true", help="run the cleanup table only") p.add_argument("--stt-models", nargs="+", default=STT_MODELS, help="backend:model specs") - p.add_argument("--cleanup-models", nargs="+", default=CLEANUP_MODELS, help="ollama model names") + p.add_argument("--cleanup-models", nargs="+", default=CLEANUP_MODELS, help="engine:model specs (mlx:… or ollama:…)") p.add_argument("--repeat", type=int, default=3, help="runs per cell, median reported") p.add_argument("--audio", type=Path, default=config.home() / "bench-audio", help="sample wav dir") p.add_argument("--url", default="http://localhost:11434", help="ollama base url") @@ -203,7 +222,7 @@ def main(argv: list[str]) -> int: } t = md_stt_table(fake, [("short", Path("x"), 6.0)]) assert "0.10s (60x)" in t and "load" in t, t - fakec = {"qwen3:4b-instruct-2507": {0: (0.2, 0.9)}, "qwen2.5:3b": {0: (0.3, 1.1)}} + fakec = {"mlx:x/Qwen3-4B-Instruct-2507-4bit": {0: (0.2, 0.9)}, "ollama:qwen3:4b": None} c = md_cleanup_table(fakec, ["x"]) - assert "0.20 / 0.90s" in c and "median" in c, c + assert "0.20 / 0.90s" in c and "n/a" in c and "median" in c, c print("bench.py: table-builder checks passed") diff --git a/v2t/cli.py b/v2t/cli.py index 997be38..9471fce 100644 --- a/v2t/cli.py +++ b/v2t/cli.py @@ -1,6 +1,7 @@ """v2t command line. v2t run push-to-talk (default) + v2t setup guided first-run config (pick models, detect Ollama) v2t bench benchmark STT + cleanup models v2t config show resolved config + paths (--init to write a template) v2t status running/idle line for the SwiftBar plugin @@ -23,7 +24,8 @@ def cmd_run(argv: list[str]) -> int: p.add_argument("--config", help="path to a config.toml") p.add_argument("--backend", choices=["parakeet", "whisper"], help="override transcription backend") p.add_argument("--model", help="override STT model") - p.add_argument("--cleanup-model", help="override Ollama cleanup model") + p.add_argument("--cleanup-engine", choices=["mlx", "ollama"], help="override cleanup engine") + p.add_argument("--cleanup-model", help="override cleanup model") p.add_argument("--no-cleanup", action="store_true", help="paste raw transcription, skip LLM cleanup") p.add_argument("--casual", action="store_true", help="light cleanup (punctuation + fillers only)") p.add_argument("--strict", action="store_true", help="full cleanup (restructures) — the default") @@ -35,6 +37,7 @@ def cmd_run(argv: list[str]) -> int: overrides = { "backend": a.backend, "stt_model": a.model, + "cleanup_engine": a.cleanup_engine, "cleanup_model": a.cleanup_model, "cleanup_enabled": False if a.no_cleanup else None, "mode": "casual" if a.casual else ("strict" if a.strict else None), @@ -73,6 +76,69 @@ def cmd_config(argv: list[str]) -> int: return 0 +_SETUP_TOML = """\ +# Written by `v2t setup`. Run `v2t config` to see every option, or edit freely. + +[transcription] +backend = "{backend}" +model = "" + +[cleanup] +enabled = {enabled} +engine = "{engine}" +model = "" +mode = "strict" +""" + + +def _ask(prompt: str, options: list[tuple[str, str]], default: int = 0) -> str: + """Numbered single-choice prompt. options = [(value, label)]; returns the value.""" + print(prompt) + for i, (_v, label) in enumerate(options): + print(f" {i + 1}) {label}{' (default)' if i == default else ''}") + raw = input(f" choice [{default + 1}]: ").strip() + idx = int(raw) - 1 if raw.isdigit() and 1 <= int(raw) <= len(options) else default + return options[idx][0] + + +def _yesno(prompt: str, default: bool = True) -> bool: + raw = input(f"{prompt} [{'Y/n' if default else 'y/N'}]: ").strip().lower() + return default if not raw else raw.startswith("y") + + +def cmd_setup(argv: list[str]) -> int: + from shutil import which + + path = config.config_path() + print(f"v2t setup → {path}\n") + if path.exists() and not _yesno(f"{path} exists. Overwrite?", default=False): + print("keeping existing config.") + return 0 + + backend = _ask("Transcription model:", [ + ("parakeet", "Parakeet — fast, multilingual (recommended)"), + ("whisper", "Whisper — best for rare languages / accents"), + ]) + engine = "mlx" + cleanup_enabled = _yesno("\nAdd an LLM cleanup pass (punctuation, fillers)?", default=True) + if cleanup_enabled: + if which("ollama"): + engine = _ask("\nOllama detected. Cleanup engine:", [ + ("mlx", "mlx-lm — in-process, no daemon (recommended)"), + ("ollama", "Ollama — reuse what you already run"), + ]) + else: + print("\nNo Ollama found — using in-process mlx-lm for cleanup.") + + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(_SETUP_TOML.format(backend=backend, enabled=str(cleanup_enabled).lower(), engine=engine)) + print(f"\nwrote {path}") + if cleanup_enabled and engine == "ollama": + print("next: ollama pull qwen3:4b-instruct-2507") + print(f"run: v2t (install with: uv tool install 'voice2text[{backend}]')") + return 0 + + def _status() -> dict | None: path = config.run_dir() / "status.json" if not path.exists(): @@ -120,7 +186,7 @@ def _which(name: str) -> bool: def main(argv: list[str] | None = None) -> int: argv = list(sys.argv[1:] if argv is None else argv) - table = {"bench": cmd_bench, "config": cmd_config, "status": cmd_status, "stop": cmd_stop} + table = {"setup": cmd_setup, "bench": cmd_bench, "config": cmd_config, "status": cmd_status, "stop": cmd_stop} if argv and argv[0] in table: return table[argv[0]](argv[1:]) return cmd_run(argv[1:] if argv and argv[0] == "run" else argv) diff --git a/v2t/config.py b/v2t/config.py index 37bace3..6ccc9eb 100644 --- a/v2t/config.py +++ b/v2t/config.py @@ -24,7 +24,8 @@ class Config: backend: str = "parakeet" # parakeet | whisper stt_model: str = "" # blank = the backend's own default cleanup_enabled: bool = True - cleanup_model: str = "qwen3:4b-instruct-2507" + cleanup_engine: str = "mlx" # mlx (in-process via mlx-lm) | ollama + cleanup_model: str = "" # blank = the engine's own default mode: str = "strict" # strict | casual hotkey: str = "cmd_r" sample_rate: int = 16000 @@ -57,7 +58,7 @@ def run_dir() -> Path: # TOML section -> Config field. Flat dataclass, sectioned file: friendlier to edit. _SECTIONS = { "transcription": {"backend": "backend", "model": "stt_model"}, - "cleanup": {"enabled": "cleanup_enabled", "model": "cleanup_model", "mode": "mode"}, + "cleanup": {"enabled": "cleanup_enabled", "engine": "cleanup_engine", "model": "cleanup_model", "mode": "mode"}, "hotkey": {"key": "hotkey"}, "audio": {"sample_rate": "sample_rate"}, "behavior": {"pause_music": "pause_music", "save_history": "save_history"}, @@ -90,7 +91,8 @@ def load(overrides: dict | None = None) -> Config: [cleanup] enabled = true -model = "qwen3:4b-instruct-2507" # any ollama model; use an -instruct (non-thinking) one +engine = "mlx" # mlx (in-process via mlx-lm, default) | ollama +model = "" # blank = engine default (Qwen3-4B-Instruct-2507-4bit / qwen3:4b-instruct-2507) mode = "strict" # strict (restructures) | casual (punctuation + fillers only) [hotkey] @@ -146,7 +148,7 @@ def append_history(record: dict) -> None: write_default() assert p.read_text() == before, "never clobbers existing config" # config.toml round-trips through the loader - assert load().cleanup_model == "qwen3:4b-instruct-2507", "toml parsed" + assert load().cleanup_engine == "mlx", "toml parsed" append_history({"raw": "héllo", "clean": "Hello."}) line = json.loads(history_path().read_text().splitlines()[-1]) From 218e0cdb8cd34863a009ea86623fdfd315d808dd Mon Sep 17 00:00:00 2001 From: lucharo Date: Tue, 16 Jun 2026 14:20:26 +0100 Subject: [PATCH 04/38] Flip packaging: Parakeet + mlx-lm in core, bare install just works MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make 'uv tool install voice2text' work out of the box on Apple Silicon — parakeet-mlx (STT) and mlx-lm (cleanup) move into core dependencies. Whisper becomes an optional alternative STT backend (voice2text[whisper]). Drops the PEP 771 'default extras' caveat (no longer relevant). Mac-first by design. https://claude.ai/code/session_01XTW6U37PNqStHyZ9BZEM6r --- CHANGELOG.md | 4 ++-- README.md | 26 ++++++++++++-------------- pyproject.toml | 14 +++++++------- uv.lock | 22 +++++----------------- v2t/backends.py | 4 ++-- 5 files changed, 28 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c285ff3..7381369 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,8 @@ The single-file proof-of-concept is preserved at the [`nano`](https://github.com/lucharo/voice2text/releases/tag/nano) tag. This release turns v2t into a small, modular, MLX-first package. -- **Parakeet is the default backend** (`mlx-community/parakeet-tdt-0.6b-v3`) — ~10× faster than Whisper on Apple Silicon, multilingual. Whisper stays available as the `whisper` backend. -- **Pluggable STT backends** as install extras: `voice2text[parakeet]` (default), `voice2text[whisper]`, `voice2text[all]`. +- **Parakeet is the default backend** (`mlx-community/parakeet-tdt-0.6b-v3`) — ~10× faster than Whisper on Apple Silicon, multilingual. Ships in core, so `uv tool install voice2text` just works. +- **Pluggable STT backends** — Whisper is an optional alternative: `uv tool install 'voice2text[whisper]'`, then select it in config. - **Pluggable cleanup engines** — default is **mlx-lm, in-process** (`Qwen3-4B-Instruct-2507`): no Ollama, no daemon, same MLX stack as transcription. Ollama stays as an optional `engine = "ollama"`. Use a non-thinking model with either. - **Guided `v2t setup`** — pick the transcription model and cleanup engine; detects Ollama and offers it, otherwise defaults to mlx-lm. Writes `~/.v2t/config.toml`. - **Config file** at `~/.v2t/config.toml` (honors `$V2T_HOME` / `$XDG_CONFIG_HOME`), with `v2t config [--init]`. diff --git a/README.md b/README.md index 8bc1a35..fba6394 100644 --- a/README.md +++ b/README.md @@ -34,37 +34,35 @@ enough now that the basics fit in a small Python package on consumer hardware. ## Install -Requires **macOS on Apple Silicon** (MLX). Cleanup runs in-process via mlx-lm — nothing else to install, no daemon. Install with the backend you want (quote the brackets — zsh treats them as globs): +Requires **macOS on Apple Silicon** (MLX). Everything's local and bundled — Parakeet transcription and in-process mlx-lm cleanup ship by default, no daemon, nothing else to pull: ```bash -uv tool install 'voice2text[parakeet]' # recommended — Parakeet STT + in-process Qwen3 cleanup -uv tool install 'voice2text[whisper]' # Whisper instead (no Parakeet) -uv tool install 'voice2text[all]' # both, switch via config -v2t setup # optional: pick models, detect Ollama, write config +uv tool install voice2text # Parakeet STT + in-process Qwen3 cleanup +v2t setup # optional: pick models, detect Ollama, write config v2t ``` -> **Note on extras:** MLX is an explicit extra so the install stays Mac-first and you pick exactly -> one STT engine; each extra also pulls `mlx-lm` for the default cleanup. A bare `voice2text` (no -> extra) installs no backend and tells you to add `[parakeet]`. -> ([PEP 771](https://peps.python.org/pep-0771/) "default extras" would let a bare install imply -> Parakeet automatically, but it isn't supported by uv/hatchling yet.) +Prefer Whisper for transcription? Add the extra (quote the brackets — zsh treats them as globs): + +```bash +uv tool install 'voice2text[whisper]' # adds the Whisper backend; select it in config +```
Other install methods (uvx, pip, dev, pixi) ```bash # quick try (fresh venv each run — slower startup) -uvx --from 'voice2text[parakeet]' v2t +uvx voice2text # pip -pip install 'voice2text[parakeet]' && v2t +pip install voice2text && v2t # from source git clone https://github.com/lucharo/voice2text.git && cd voice2text -uv sync --extra parakeet && uv run v2t +uv sync && uv run v2t -# pixi (installs the parakeet extra) +# pixi pixi run v2t ```
diff --git a/pyproject.toml b/pyproject.toml index f6f617a..8bb65e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,15 +11,15 @@ dependencies = [ "scipy", "pynput", "loguru", + "parakeet-mlx", + "mlx-lm", ] -# MLX backends are extras (Apple Silicon only). Parakeet is the recommended -# default; install it explicitly. Picking `whisper` keeps Parakeet out. Each -# extra also pulls mlx-lm for the default in-process cleanup (no Ollama needed). +# Mac-first: Parakeet (STT) + mlx-lm (cleanup) ship in core, so a bare +# `uv tool install voice2text` just works on Apple Silicon. Whisper is an +# optional alternative STT backend. [project.optional-dependencies] -parakeet = ["parakeet-mlx", "mlx-lm"] -whisper = ["mlx-whisper", "mlx-lm"] -all = ["parakeet-mlx", "mlx-whisper", "mlx-lm"] +whisper = ["mlx-whisper"] [project.scripts] v2t = "v2t.cli:main" @@ -44,7 +44,7 @@ ollama = "*" # nowplaying-cli not on conda-forge, install via: brew install nowplaying-cli [tool.pixi.pypi-dependencies] -voice2text = { path = ".", editable = true, extras = ["parakeet"] } +voice2text = { path = ".", editable = true } [tool.pixi.tasks] v2t = "v2t" diff --git a/uv.lock b/uv.lock index f28b578..cbde426 100644 --- a/uv.lock +++ b/uv.lock @@ -2408,24 +2408,16 @@ version = "0.3.0" source = { editable = "." } dependencies = [ { name = "loguru" }, + { name = "mlx-lm" }, { name = "numpy" }, + { name = "parakeet-mlx" }, { name = "pynput" }, { name = "scipy" }, { name = "sounddevice" }, ] [package.optional-dependencies] -all = [ - { name = "mlx-lm" }, - { name = "mlx-whisper" }, - { name = "parakeet-mlx" }, -] -parakeet = [ - { name = "mlx-lm" }, - { name = "parakeet-mlx" }, -] whisper = [ - { name = "mlx-lm" }, { name = "mlx-whisper" }, ] @@ -2438,19 +2430,15 @@ dev = [ [package.metadata] requires-dist = [ { name = "loguru" }, - { name = "mlx-lm", marker = "extra == 'all'" }, - { name = "mlx-lm", marker = "extra == 'parakeet'" }, - { name = "mlx-lm", marker = "extra == 'whisper'" }, - { name = "mlx-whisper", marker = "extra == 'all'" }, + { name = "mlx-lm" }, { name = "mlx-whisper", marker = "extra == 'whisper'" }, { name = "numpy" }, - { name = "parakeet-mlx", marker = "extra == 'all'" }, - { name = "parakeet-mlx", marker = "extra == 'parakeet'" }, + { name = "parakeet-mlx" }, { name = "pynput" }, { name = "scipy" }, { name = "sounddevice" }, ] -provides-extras = ["parakeet", "whisper", "all"] +provides-extras = ["whisper"] [package.metadata.requires-dev] dev = [ diff --git a/v2t/backends.py b/v2t/backends.py index 0476c23..be48c04 100644 --- a/v2t/backends.py +++ b/v2t/backends.py @@ -24,7 +24,7 @@ def __init__(self, model: str = ""): try: from parakeet_mlx import from_pretrained except ImportError as e: - raise SystemExit("parakeet backend needs: uv tool install 'voice2text[parakeet]' (Apple Silicon).") from e + raise SystemExit("parakeet-mlx missing — reinstall voice2text (Apple Silicon only).") from e self.model = from_pretrained(model or self.default_model) def transcribe(self, wav_path: str) -> str: @@ -83,7 +83,7 @@ def __init__(self, model: str = "", url: str = ""): try: from mlx_lm import load, stream_generate except ImportError as e: - raise SystemExit("mlx cleanup needs mlx-lm — uv tool install 'voice2text[parakeet]' bundles it.") from e + raise SystemExit("mlx-lm missing — reinstall voice2text (Apple Silicon only).") from e self._stream = stream_generate self.model_id = model or self.default_model self.model, self.tokenizer = load(self.model_id) From 9b6ee240fd1fdbca4a94801a9371d409dfa6cd38 Mon Sep 17 00:00:00 2001 From: lucharo Date: Tue, 16 Jun 2026 14:55:26 +0100 Subject: [PATCH 05/38] Benchmark on M1 Pro: warm up STT before timing; record results + quality - bench_stt warms each clip once before the timed runs (MLX compiles per input shape; the app warms up too), so cells reflect steady-state, not cold-compile. - Add benchmarks/results/2026-06-16-Apple-M1-Pro.md (real numbers). - Fill GRID.md M1 Pro rows + a Transcript quality section: local say-sample WER (~0-2%, sanity only) plus published Open ASR Leaderboard WER (v2 ~6.0%, v3 ~6.3%, whisper-turbo ~7.5-8%). Validates Parakeet v3 default (fastest + ~6.3%). https://claude.ai/code/session_01XTW6U37PNqStHyZ9BZEM6r --- benchmarks/results/2026-06-16-Apple-M1-Pro.md | 25 ++++++++++++++++ benchmarks/results/GRID.md | 29 +++++++++++++++++-- v2t/bench.py | 1 + 3 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 benchmarks/results/2026-06-16-Apple-M1-Pro.md diff --git a/benchmarks/results/2026-06-16-Apple-M1-Pro.md b/benchmarks/results/2026-06-16-Apple-M1-Pro.md new file mode 100644 index 0000000..42f579b --- /dev/null +++ b/benchmarks/results/2026-06-16-Apple-M1-Pro.md @@ -0,0 +1,25 @@ +# v2t benchmark — Apple-M1-Pro — 2026-06-16 + +_Compare models within this file; absolute times are machine-specific._ + +**Speech-to-text** — time & RTF + +| sample | Parakeet parakeet-tdt-0.6b-v3 | Parakeet parakeet-tdt-0.6b-v2 | Whisper whisper-large-v3-turbo | +|---|--:|--:|--:| +| load | 2.0s | 0.8s | 0.1s | +| short (3.4s) | 0.17s (21x) | 0.18s (19x) | 1.49s (2x) | +| medium (10.5s) | 0.29s (36x) | 0.27s (40x) | 1.61s (7x) | +| long (28.5s) | 0.64s (44x) | 0.58s (49x) | 1.92s (15x) | + +_Cells: transcription time (real-time factor). Higher RTF = faster._ + +**Text cleanup** — TTFT & total + +| sample | mlx:Qwen3-4B-Instruct-2507-4bit | mlx:Qwen2.5-3B-Instruct-4bit | +|---|--:|--:| +| 0 | 0.55 / 0.97s | 0.32 / 0.51s | +| 1 | 0.42 / 0.66s | 0.30 / 0.48s | +| 2 | 0.56 / 0.98s | 0.29 / 0.53s | +| **median** | 0.55 / 0.97s | 0.30 / 0.51s | + +_Cells: time-to-first-token / total. Lower is better._ diff --git a/benchmarks/results/GRID.md b/benchmarks/results/GRID.md index 3703fd3..d2016f7 100644 --- a/benchmarks/results/GRID.md +++ b/benchmarks/results/GRID.md @@ -11,23 +11,46 @@ _Lower time / higher RTF is better. Needs Apple Silicon (MLX)._ | machine | parakeet-tdt-0.6b-v3 | parakeet-tdt-0.6b-v2 | whisper-large-v3-turbo | |---|--:|--:|--:| | Linux x86 (remote container) | n/a — no MLX | n/a — no MLX | n/a — no MLX | -| MacBook (personal) | _todo_ | _todo_ | _todo_ | +| MacBook (personal) — M1 Pro | 0.29s (36×) | 0.27s (40×) | 1.61s (7×) | | MacBook (work) | _todo_ | _todo_ | _todo_ | ## Text cleanup — TTFT / total on the median sample -_Lower is better. Needs Ollama._ +_Lower is better. mlx engine needs Apple Silicon; ollama engine needs Ollama._ | machine | mlx:Qwen3-4B-Instruct-2507-4bit | mlx:Qwen2.5-3B-Instruct-4bit | ollama:qwen3:4b-instruct-2507 | |---|--:|--:|--:| | Linux x86 (remote container) | n/a — no MLX | n/a — no MLX | _todo¹_ | -| MacBook (personal) | _todo_ | _todo_ | _todo_ | +| MacBook (personal) — M1 Pro | 0.55 / 0.97s | 0.30 / 0.51s | _not run_ | | MacBook (work) | _todo_ | _todo_ | _todo_ | ¹ The default cleanup (mlx-lm) needs Apple Silicon. The remote Linux box can only run the `ollama:` column, and only once Ollama is installed — CPU x86 timings aren't representative of a Mac, so treat that cell as a sanity check, not a target. +_Detailed per-run numbers: [`2026-06-16-Apple-M1-Pro.md`](2026-06-16-Apple-M1-Pro.md)._ + +## Transcript quality (accuracy) + +Speed ≠ accuracy. The benchmark clips are macOS `say` (synthetic, clean), so all +three STT models score ~0–2% WER on them locally — a sanity check (they transcribe +correctly and punctuate/capitalise well), too easy to separate models. + +For real-world accuracy, trust the [Open ASR Leaderboard](https://huggingface.co/spaces/hf-audio/open_asr_leaderboard), +which computes WER across 8 datasets with proper normalisation (more rigorous than +a quick local run): + +| model | English avg WER | notes | +|---|--:|---| +| parakeet-tdt-0.6b-v2 | ~6.0% | English-only; topped the leaderboard | +| **parakeet-tdt-0.6b-v3** (default) | ~6.3% | multilingual; small English cost vs v2 | +| whisper-large-v3-turbo | ~7.5–8% | distilled large-v3, trades accuracy for speed | + +So the default (**Parakeet v3**) is ~6.3% WER *and* the fastest here — the right +call. Switch to v2 (`model = "mlx-community/parakeet-tdt-0.6b-v2"`) for a hair more +English accuracy if you never need other languages. Cleanup can't recover missing +words, so STT WER is the accuracy that matters; punctuation/casing it fixes anyway. + --- **How to fill this in** diff --git a/v2t/bench.py b/v2t/bench.py index 1b5643d..3270f8f 100644 --- a/v2t/bench.py +++ b/v2t/bench.py @@ -102,6 +102,7 @@ def bench_stt(model_specs: list[str], samples, repeat: int) -> dict: stt = backends.make_stt(backend, model) results[spec] = {"load": time.perf_counter() - t0} for name, wav, dur in samples: + stt.transcribe(str(wav)) # warm up: MLX compiles per input shape; the app warms up too times = [] for _ in range(repeat): t = time.perf_counter() From 3fb7e797308d41b2a6010959238b5980b22f10bd Mon Sep 17 00:00:00 2001 From: lucharo Date: Wed, 17 Jun 2026 12:28:44 +0100 Subject: [PATCH 06/38] SwiftBar: live state icon + instant feedback; ponytail cuts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design tenet: an ergonomic tool is communicative — every action gets immediate visible feedback. - App writes live state (starting/idle/recording/transcribing/cleaning) to status.json on each transition and pushes an instant SwiftBar repaint (swiftbar://refreshplugin). Fixes 'Start does nothing' — the icon now flips to 'starting' the moment v2t launches instead of staying dead for the ~37s warmup. - Plugin colors the menu-bar icon per state (🟠/🟢/🔴/🟡) and shows what it's doing. - Single-instance guard (no more accidental double-launch); non-tty permission guard so a headless/SwiftBar start can't hang on input(). - Consolidate liveness onto config.read_status(); drop the dead v2t.pid file. - ponytail: drop the Ollama HTTP->subprocess fallback chain (URLError now flows to the app's existing raw-text fallback); boring md_stt_table header; -unused imports. https://claude.ai/code/session_01XTW6U37PNqStHyZ9BZEM6r --- .gitignore | 4 ++++ README.md | 5 +++++ swiftbar/v2t.5s.sh | 28 ++++++++++++++++---------- v2t/app.py | 40 ++++++++++++++++++++++++++++--------- v2t/backends.py | 50 +++++++++++++++++++--------------------------- v2t/bench.py | 10 +++++++--- v2t/cli.py | 29 +++++---------------------- v2t/config.py | 24 ++++++++++++++++++++-- 8 files changed, 112 insertions(+), 78 deletions(-) diff --git a/.gitignore b/.gitignore index 2d43501..778519e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,7 @@ dist/ !.pixi/config.toml .env + +# agent skills installed via `npx skills add` (local tooling) +.agents/ +skills-lock.json diff --git a/README.md b/README.md index fba6394..cb92186 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,11 @@ enough now that the basics fit in a small Python package on consumer hardware. > From `0.3.0` it's a small, modular package: pluggable MLX backends, one config file, a benchmark > harness, and a SwiftBar menu-bar toggle. +> **Design tenet — be communicative.** An ergonomic tool is an expressive one: every action gets +> immediate, visible feedback. The menu-bar icon tracks live state (off · starting · ready · +> recording · transcribing · cleaning) and repaints the instant anything changes, so you always +> know the tool heard you. + ## What you get - **Push-to-talk** — hold Right ⌘ (configurable), release to transcribe + paste. diff --git a/swiftbar/v2t.5s.sh b/swiftbar/v2t.5s.sh index fadc66a..865502a 100755 --- a/swiftbar/v2t.5s.sh +++ b/swiftbar/v2t.5s.sh @@ -10,7 +10,8 @@ # true # # Install: brew install swiftbar, then drop this file in your SwiftBar plugins -# folder (keep the `.5s.sh` suffix — it sets the 5-second refresh). +# folder (keep the `.5s.sh` suffix — a 5s safety refresh; v2t also pushes an +# instant repaint on every state change, so the icon tracks live activity). # # "Start v2t" from the menu needs SwiftBar to have Accessibility + Input # Monitoring permissions (System Settings > Privacy). Without them, start v2t @@ -38,20 +39,27 @@ if [ -z "$V2T_BIN" ]; then exit 0 fi +# `v2t status` -> "off" or "\t\t" STATUS="$("$V2T_BIN" status 2>/dev/null)" STATE="$(printf '%s' "$STATUS" | cut -f1)" +MODEL="$(printf '%s' "$STATUS" | cut -f2)" +MODE="$(printf '%s' "$STATUS" | cut -f3)" -if [ "$STATE" = "running" ]; then - MODEL="$(printf '%s' "$STATUS" | cut -f3)" - MODE="$(printf '%s' "$STATUS" | cut -f4)" - echo "🎙️ | color=#34c759" - echo "---" - echo "● Running ($MODEL · $MODE) | color=#34c759" +case "$STATE" in + starting) TITLE="🎙️🟠"; LINE="🟠 Starting… (loading models)" ; RUN=1 ;; + idle) TITLE="🎙️🟢"; LINE="🟢 Ready ($MODEL · $MODE)" ; RUN=1 ;; + recording) TITLE="🎙️🔴"; LINE="🔴 Recording…" ; RUN=1 ;; + transcribing) TITLE="🎙️🟡"; LINE="🟡 Transcribing…" ; RUN=1 ;; + cleaning) TITLE="🎙️🟡"; LINE="🟡 Cleaning up…" ; RUN=1 ;; + *) TITLE="🎙️"; LINE="○ Off" ; RUN=0 ;; +esac + +echo "$TITLE" +echo "---" +echo "$LINE" +if [ "$RUN" = "1" ]; then echo "Stop v2t | bash=\"$0\" param0=stop terminal=false refresh=true" else - echo "🎙️ | color=#8e8e93" - echo "---" - echo "○ Idle | color=#8e8e93" echo "Start v2t | bash=\"$0\" param0=start terminal=false refresh=true" fi echo "---" diff --git a/v2t/app.py b/v2t/app.py index f7b7be2..b319f59 100644 --- a/v2t/app.py +++ b/v2t/app.py @@ -33,6 +33,14 @@ def short_model(name: str) -> str: return tail +def _refresh_swiftbar() -> None: + """Best-effort: tell SwiftBar to repaint the menu-bar icon right now.""" + try: + subprocess.run(["open", "-g", "swiftbar://refreshplugin?name=v2t"], check=False, capture_output=True) + except OSError: + pass + + def _resolve_hotkey(name: str): from pynput import keyboard @@ -56,6 +64,10 @@ def check_and_request_permissions() -> None: if "not allowed" in test.stderr.lower() or test.returncode != 0: logger.warning("Permissions needed! Grant them to your TERMINAL APP (or SwiftBar if you start v2t from the menu).") subprocess.run(["open", "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"]) + if not sys.stdin.isatty(): + # Launched headless (e.g. SwiftBar): can't prompt. Tell the user and exit cleanly. + logger.error("Grant Accessibility + Input Monitoring in System Settings, then start v2t again.") + sys.exit(1) input("Press Enter after granting Accessibility permission...") subprocess.run(["open", "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent"]) input("Press Enter after granting Input Monitoring permission...") @@ -77,23 +89,23 @@ def __init__(self, cfg: Config): self.record_start = 0.0 self.was_playing = False - # --- status file for the SwiftBar plugin ------------------------------- - def _write_status(self) -> None: + # --- live status for the SwiftBar plugin ------------------------------- + # state: starting | idle | recording | transcribing | cleaning. Written on + # every transition (and the icon repainted) so actions get instant feedback. + def _set_state(self, state: str) -> None: d = config.run_dir() d.mkdir(parents=True, exist_ok=True) - (d / "v2t.pid").write_text(str(os.getpid())) (d / "status.json").write_text(json.dumps({ "pid": os.getpid(), - "backend": self.cfg.backend, "model": short_model(self.stt_model), "mode": self.cfg.mode, - "started": time.strftime("%Y-%m-%dT%H:%M:%S"), + "state": state, })) + _refresh_swiftbar() def _clear_status(self) -> None: - d = config.run_dir() - for name in ("v2t.pid", "status.json"): - (d / name).unlink(missing_ok=True) + (config.run_dir() / "status.json").unlink(missing_ok=True) + _refresh_swiftbar() # --- recording ---------------------------------------------------------- def audio_callback(self, indata, frame_count, time_info, status): @@ -106,6 +118,7 @@ def start_recording(self): self.recording = True self.frames = [] self.record_start = time.perf_counter() + self._set_state("recording") if self.cfg.pause_music: r = subprocess.run(["nowplaying-cli", "get", "playbackRate"], capture_output=True, text=True) self.was_playing = r.stdout.strip() == "1" @@ -129,10 +142,13 @@ def stop_recording(self): logger.info(f"Stopped ({duration:.1f}s)") if self.frames: threading.Thread(target=self.process_audio, args=(duration,), daemon=True).start() + else: + self._set_state("idle") def process_audio(self, audio_s: float): self.processing = True try: + self._set_state("transcribing") audio = np.concatenate(self.frames, axis=0) with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: wavfile.write(f.name, self.cfg.sample_rate, (audio * 32767).astype(np.int16)) @@ -149,6 +165,7 @@ def process_audio(self, audio_s: float): cleaned_text, cleanup_s = raw_text, 0.0 if self.cleaner is not None: + self._set_state("cleaning") logger.info("Cleaning up...") try: cleaned_text, _ttft, cleanup_s = self.cleaner.cleanup(raw_text, self.cfg.mode) @@ -175,6 +192,7 @@ def process_audio(self, audio_s: float): if self.cfg.pause_music and self.was_playing: subprocess.run(["nowplaying-cli", "play"]) self.processing = False + self._set_state("idle") def paste_to_cursor(self, text: str) -> None: """Copy, paste at cursor, restore the previous clipboard.""" @@ -214,9 +232,13 @@ def warmup(self): def run(self): from pynput import keyboard + if (other := config.read_status()) is not None: + logger.error(f"v2t already running (pid {other['pid']}). Stop it first: v2t stop") + sys.exit(1) self.hotkey = _resolve_hotkey(self.cfg.hotkey) + self._set_state("starting") # instant feedback before the slow model warmup self.warmup() - self._write_status() + self._set_state("idle") signal.signal(signal.SIGTERM, lambda *_: (self._clear_status(), sys.exit(0))) logger.info(f"Voice-to-Text — {self.cfg.backend} · {self.cfg.mode}") diff --git a/v2t/backends.py b/v2t/backends.py index be48c04..52edc1d 100644 --- a/v2t/backends.py +++ b/v2t/backends.py @@ -8,9 +8,7 @@ from __future__ import annotations import json -import subprocess import time -import urllib.error import urllib.request PARAKEET_DEFAULT = "mlx-community/parakeet-tdt-0.6b-v3" @@ -110,34 +108,26 @@ def __init__(self, model: str = "", url: str = "http://localhost:11434"): def cleanup(self, text: str, mode: str = "strict", timeout: int = 60): prompt = PROMPTS[mode].format(text=text) - t0 = time.perf_counter() - try: - req = urllib.request.Request( - f"{self.url}/api/generate", - data=json.dumps({"model": self.model_id, "prompt": prompt, "stream": True}).encode(), - headers={"Content-Type": "application/json"}, - ) - ttft, parts = None, [] - with urllib.request.urlopen(req, timeout=timeout) as r: - for line in r: - if not line.strip(): - continue - chunk = json.loads(line) - if (piece := chunk.get("response", "")): - if ttft is None: - ttft = time.perf_counter() - t0 - parts.append(piece) - if chunk.get("error"): - raise RuntimeError(chunk["error"]) - if chunk.get("done"): - break - return "".join(parts).strip(), ttft, time.perf_counter() - t0 - except urllib.error.URLError: - # ponytail: server not reachable; the CLI auto-starts it. No streaming, so no TTFT. - res = subprocess.run(["ollama", "run", self.model_id, prompt], capture_output=True, text=True, timeout=timeout) - if res.returncode != 0: - raise RuntimeError(res.stderr.strip() or "ollama run failed") - return res.stdout.strip(), None, time.perf_counter() - t0 + req = urllib.request.Request( + f"{self.url}/api/generate", + data=json.dumps({"model": self.model_id, "prompt": prompt, "stream": True}).encode(), + headers={"Content-Type": "application/json"}, + ) + t0, ttft, parts = time.perf_counter(), None, [] + with urllib.request.urlopen(req, timeout=timeout) as r: # needs a running ollama server + for line in r: + if not line.strip(): + continue + chunk = json.loads(line) + if (piece := chunk.get("response", "")): + if ttft is None: + ttft = time.perf_counter() - t0 + parts.append(piece) + if chunk.get("error"): + raise RuntimeError(chunk["error"]) + if chunk.get("done"): + break + return "".join(parts).strip(), ttft, time.perf_counter() - t0 CLEANUP = {"mlx": MLXCleanup, "ollama": OllamaCleanup} diff --git a/v2t/bench.py b/v2t/bench.py index 3270f8f..3a179e5 100644 --- a/v2t/bench.py +++ b/v2t/bench.py @@ -141,10 +141,14 @@ def bench_cleanup(specs: list[str], samples: list[str], repeat: int, url: str) - def md_stt_table(results: dict, samples) -> str: specs = list(results) - head = "| sample | " + " | ".join(backends.STT[s.split(':')[0]].__name__.replace("STT", "") + - " " + (s.split(':')[1].rsplit('/', 1)[-1]) for s in specs) + " |" + + def label(s: str) -> str: + backend, _, model = s.partition(":") + return f"{backend} {model.rsplit('/', 1)[-1]}" + + head = "| sample | " + " | ".join(label(s) for s in specs) + " |" sep = "|---|" + "|".join("--:" for _ in specs) + "|" - rows = [f"| load | " + " | ".join(f"{results[s]['load']:.1f}s" for s in specs) + " |"] + rows = ["| load | " + " | ".join(f"{results[s]['load']:.1f}s" for s in specs) + " |"] for name, _wav, dur in samples: cells = " | ".join(f"{results[s][name][0]:.2f}s ({results[s][name][1]:.0f}x)" for s in specs) rows.append(f"| {name} ({dur:.1f}s) | {cells} |") diff --git a/v2t/cli.py b/v2t/cli.py index 9471fce..7034ed6 100644 --- a/v2t/cli.py +++ b/v2t/cli.py @@ -4,14 +4,13 @@ v2t setup guided first-run config (pick models, detect Ollama) v2t bench benchmark STT + cleanup models v2t config show resolved config + paths (--init to write a template) - v2t status running/idle line for the SwiftBar plugin + v2t status live state line (off / starting / idle / recording / …) v2t stop stop a running v2t """ from __future__ import annotations import argparse -import json import os import signal import sys @@ -139,33 +138,15 @@ def cmd_setup(argv: list[str]) -> int: return 0 -def _status() -> dict | None: - path = config.run_dir() / "status.json" - if not path.exists(): - return None - data = json.loads(path.read_text()) - try: - os.kill(data["pid"], 0) # signal 0 = liveness probe - except ProcessLookupError: - for f in ("v2t.pid", "status.json"): - (config.run_dir() / f).unlink(missing_ok=True) - return None - except PermissionError: - pass # alive but owned by someone else - return data - - def cmd_status(argv: list[str]) -> int: - s = _status() - if s is None: - print("idle") - else: - print(f"running\t{s['backend']}\t{s['model']}\t{s['mode']}") + """For the SwiftBar plugin: 'off', or '\t\t'.""" + s = config.read_status() + print("off" if s is None else f"{s['state']}\t{s['model']}\t{s['mode']}") return 0 def cmd_stop(argv: list[str]) -> int: - s = _status() + s = config.read_status() if s is None: print("not running") return 1 diff --git a/v2t/config.py b/v2t/config.py index 6ccc9eb..e97dd02 100644 --- a/v2t/config.py +++ b/v2t/config.py @@ -3,7 +3,7 @@ Everything lives under ~/.v2t (or $V2T_HOME, or $XDG_CONFIG_HOME/v2t): config.toml user settings history/transcriptions.jsonl every transcription + metadata - run/ pid + status for the SwiftBar plugin + run/status.json live state for the SwiftBar plugin Zero config works: the defaults below are the shipped behaviour (Parakeet + Qwen3, MLX, strict cleanup). @@ -14,7 +14,7 @@ import json import os import tomllib -from dataclasses import asdict, dataclass +from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path @@ -55,6 +55,26 @@ def run_dir() -> Path: return home() / "run" +def read_status() -> dict | None: + """The running v2t's status (pid, model, mode, state), or None. Cleans a stale file.""" + path = run_dir() / "status.json" + if not path.exists(): + return None + try: + data = json.loads(path.read_text()) + pid = data["pid"] + except (ValueError, KeyError, OSError): + return None + try: + os.kill(pid, 0) # signal 0 = liveness probe + except ProcessLookupError: + path.unlink(missing_ok=True) + return None + except PermissionError: + pass # alive but owned by someone else + return data + + # TOML section -> Config field. Flat dataclass, sectioned file: friendlier to edit. _SECTIONS = { "transcription": {"backend": "backend", "model": "stt_model"}, From b031eaf19360752fcab1426e8c82709e6174e246 Mon Sep 17 00:00:00 2001 From: lucharo Date: Wed, 17 Jun 2026 12:37:25 +0100 Subject: [PATCH 07/38] Revert .gitignore band-aid: ponytail skills live at user level (~/.agents) https://claude.ai/code/session_01XTW6U37PNqStHyZ9BZEM6r --- .gitignore | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.gitignore b/.gitignore index 778519e..2d43501 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,3 @@ dist/ !.pixi/config.toml .env - -# agent skills installed via `npx skills add` (local tooling) -.agents/ -skills-lock.json From b2f3b8c0114f85cfeaeeb30d45b6086fa9346ba9 Mon Sep 17 00:00:00 2001 From: lucharo Date: Wed, 17 Jun 2026 12:56:10 +0100 Subject: [PATCH 08/38] SwiftBar: permissions submenu, models line; fix silent-mic + history dir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the first real record->paste run: - 'Text went nowhere' was dead-silent audio = missing Microphone permission for the launching app. Detect peak<1e-4 and say so + open the Mic pane (once), instead of failing silently. - 'Open transcription history' opened nothing because ~/.v2t/history didn't exist yet — create run/ + history/ eagerly (app startup + plugin). - Add a Permissions submenu (Microphone / Accessibility / Input Monitoring) that opens each System Settings pane — grant, then restart the launching app. - Show the active models in the menu (parakeet-v3 · clean: Qwen3-4B). v2t status now reports state + STT + cleanup + mode (settings from config, not status.json). - Move short_model to backends so 'v2t status' stays light (no audio import). https://claude.ai/code/session_01XTW6U37PNqStHyZ9BZEM6r --- README.md | 12 +++++++++--- swiftbar/v2t.5s.sh | 26 ++++++++++++++++++-------- v2t/app.py | 32 +++++++++++++------------------- v2t/backends.py | 19 +++++++++++++++++++ v2t/cli.py | 11 +++++++++-- 5 files changed, 68 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index cb92186..cfbd088 100644 --- a/README.md +++ b/README.md @@ -155,9 +155,15 @@ brew install swiftbar cp swiftbar/v2t.5s.sh "$HOME/Library/Application Support/SwiftBar/Plugins/" # your plugins folder ``` -The menu shows running/idle, the current model · mode, a Start/Stop toggle, and links to open -your config and transcription history. **Start from the menu** needs SwiftBar to have Accessibility + -Input Monitoring permissions; otherwise start `v2t` in a terminal and use the menu for status/stop. +The menu shows a colored live-state icon (off · starting · ready · recording · transcribing · +cleaning), the active models, a Start/Stop toggle, a **Permissions** submenu, and links to open +your config and transcription history. + +**Permissions.** v2t needs three grants, given to the app that *launches* it (your terminal, or +SwiftBar if you use "Start v2t"): **Microphone** (record), **Accessibility** + **Input Monitoring** +(hotkey + paste). The Permissions submenu opens each pane — grant them, then **restart the launching +app** (macOS only applies the grant on relaunch). If audio comes back silent, that's a missing +Microphone grant: v2t says so and opens the Mic pane. ## Models & benchmarks diff --git a/swiftbar/v2t.5s.sh b/swiftbar/v2t.5s.sh index 865502a..2daf1bb 100755 --- a/swiftbar/v2t.5s.sh +++ b/swiftbar/v2t.5s.sh @@ -13,12 +13,15 @@ # folder (keep the `.5s.sh` suffix — a 5s safety refresh; v2t also pushes an # instant repaint on every state change, so the icon tracks live activity). # -# "Start v2t" from the menu needs SwiftBar to have Accessibility + Input -# Monitoring permissions (System Settings > Privacy). Without them, start v2t -# from a terminal and use the menu just for status / stop / opening folders. +# v2t needs three permissions, granted to the app that LAUNCHES it (your terminal, +# or SwiftBar if you use "Start v2t"): Microphone (to record), Accessibility + +# Input Monitoring (to read the hotkey and paste). Use the Permissions submenu +# below, then RESTART that app — macOS only applies the grant on relaunch. export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" V2T_HOME="${V2T_HOME:-$HOME/.v2t}" +mkdir -p "$V2T_HOME/run" "$V2T_HOME/history" # so the Open… items always have a target +SEC="x-apple.systempreferences:com.apple.preference.security" V2T_BIN="$(command -v v2t || true)" for c in "$HOME/.local/bin/v2t" /opt/homebrew/bin/v2t /usr/local/bin/v2t; do @@ -27,7 +30,7 @@ done # SwiftBar re-invokes this script with a param when a menu item is clicked. case "$1" in - start) mkdir -p "$V2T_HOME/run"; nohup "$V2T_BIN" >>"$V2T_HOME/run/v2t.log" 2>&1 & exit 0 ;; + start) nohup "$V2T_BIN" >>"$V2T_HOME/run/v2t.log" 2>&1 & exit 0 ;; stop) "$V2T_BIN" stop >/dev/null 2>&1; exit 0 ;; esac @@ -39,15 +42,17 @@ if [ -z "$V2T_BIN" ]; then exit 0 fi -# `v2t status` -> "off" or "\t\t" +# `v2t status` -> "\t\t\t" (state is 'off' when not running) STATUS="$("$V2T_BIN" status 2>/dev/null)" STATE="$(printf '%s' "$STATUS" | cut -f1)" -MODEL="$(printf '%s' "$STATUS" | cut -f2)" -MODE="$(printf '%s' "$STATUS" | cut -f3)" +STT="$(printf '%s' "$STATUS" | cut -f2)" +CLEANUP="$(printf '%s' "$STATUS" | cut -f3)" +MODE="$(printf '%s' "$STATUS" | cut -f4)" +[ "$CLEANUP" = "off" ] && CLEAN_LBL="no cleanup" || CLEAN_LBL="clean: $CLEANUP" case "$STATE" in starting) TITLE="🎙️🟠"; LINE="🟠 Starting… (loading models)" ; RUN=1 ;; - idle) TITLE="🎙️🟢"; LINE="🟢 Ready ($MODEL · $MODE)" ; RUN=1 ;; + idle) TITLE="🎙️🟢"; LINE="🟢 Ready ($MODE)" ; RUN=1 ;; recording) TITLE="🎙️🔴"; LINE="🔴 Recording…" ; RUN=1 ;; transcribing) TITLE="🎙️🟡"; LINE="🟡 Transcribing…" ; RUN=1 ;; cleaning) TITLE="🎙️🟡"; LINE="🟡 Cleaning up…" ; RUN=1 ;; @@ -57,12 +62,17 @@ esac echo "$TITLE" echo "---" echo "$LINE" +echo "$STT · $CLEAN_LBL | color=#8e8e93 size=12" if [ "$RUN" = "1" ]; then echo "Stop v2t | bash=\"$0\" param0=stop terminal=false refresh=true" else echo "Start v2t | bash=\"$0\" param0=start terminal=false refresh=true" fi echo "---" +echo "Permissions (grant, then restart the launching app)" +echo "--🎙️ Microphone | bash=/usr/bin/open param0=\"$SEC?Privacy_Microphone\" terminal=false" +echo "--♿ Accessibility | bash=/usr/bin/open param0=\"$SEC?Privacy_Accessibility\" terminal=false" +echo "--⌨️ Input Monitoring | bash=/usr/bin/open param0=\"$SEC?Privacy_ListenEvent\" terminal=false" echo "Open config (~/.v2t) | bash=/usr/bin/open param0=\"$V2T_HOME\" terminal=false" echo "Open transcription history | bash=/usr/bin/open param0=\"$V2T_HOME/history\" terminal=false" echo "Refresh | refresh=true" diff --git a/v2t/app.py b/v2t/app.py index b319f59..6af7d56 100644 --- a/v2t/app.py +++ b/v2t/app.py @@ -23,14 +23,7 @@ from .config import Config -def short_model(name: str) -> str: - """parakeet-tdt-0.6b-v3 -> parakeet-v3, whisper-large-v3-turbo -> whisper-turbo.""" - tail = name.rsplit("/", 1)[-1] - for short in ("parakeet-v3", "parakeet-v2", "whisper-turbo"): - a, b = short.split("-") - if a in tail and b in tail: - return short - return tail +MIC_PANE = "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone" def _refresh_swiftbar() -> None: @@ -88,6 +81,7 @@ def __init__(self, cfg: Config): self.stream = None self.record_start = 0.0 self.was_playing = False + self._warned_mic = False # --- live status for the SwiftBar plugin ------------------------------- # state: starting | idle | recording | transcribing | cleaning. Written on @@ -95,12 +89,7 @@ def __init__(self, cfg: Config): def _set_state(self, state: str) -> None: d = config.run_dir() d.mkdir(parents=True, exist_ok=True) - (d / "status.json").write_text(json.dumps({ - "pid": os.getpid(), - "model": short_model(self.stt_model), - "mode": self.cfg.mode, - "state": state, - })) + (d / "status.json").write_text(json.dumps({"pid": os.getpid(), "state": state})) _refresh_swiftbar() def _clear_status(self) -> None: @@ -150,6 +139,13 @@ def process_audio(self, audio_s: float): try: self._set_state("transcribing") audio = np.concatenate(self.frames, axis=0) + if float(np.abs(audio).max()) < 1e-4: # dead silence == no mic access, not a quiet room + logger.error("No audio captured. Grant Microphone permission to the app that launched " + "v2t (your terminal, or SwiftBar), then RESTART that app.") + if not self._warned_mic: + self._warned_mic = True + subprocess.run(["open", MIC_PANE]) + return with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: wavfile.write(f.name, self.cfg.sample_rate, (audio * 32767).astype(np.int16)) temp_path = f.name @@ -235,6 +231,7 @@ def run(self): if (other := config.read_status()) is not None: logger.error(f"v2t already running (pid {other['pid']}). Stop it first: v2t stop") sys.exit(1) + config.history_path().parent.mkdir(parents=True, exist_ok=True) # so 'Open history' always works self.hotkey = _resolve_hotkey(self.cfg.hotkey) self._set_state("starting") # instant feedback before the slow model warmup self.warmup() @@ -255,8 +252,5 @@ def run(self): if __name__ == "__main__": - # ponytail: only the pure helper is testable off-Mac; the rest needs audio + MLX. - assert short_model("mlx-community/parakeet-tdt-0.6b-v3") == "parakeet-v3" - assert short_model("mlx-community/whisper-large-v3-turbo") == "whisper-turbo" - assert short_model("custom/my-model") == "my-model" - print("app.py: helper checks passed") + # Logic here needs audio + MLX; pure helpers/tests live in backends.py & config.py. + print("app.py: import OK") diff --git a/v2t/backends.py b/v2t/backends.py index 52edc1d..7f135fb 100644 --- a/v2t/backends.py +++ b/v2t/backends.py @@ -139,10 +139,29 @@ def make_cleanup(engine: str, model: str = "", url: str = "http://localhost:1143 return CLEANUP[engine](model, url) +_LABELS = { + "parakeet-tdt-0.6b-v3": "parakeet-v3", + "parakeet-tdt-0.6b-v2": "parakeet-v2", + "whisper-large-v3-turbo": "whisper-turbo", + "Qwen3-4B-Instruct-2507-4bit": "Qwen3-4B", + "Qwen2.5-3B-Instruct-4bit": "Qwen2.5-3B", + "qwen3:4b-instruct-2507": "qwen3:4b", +} + + +def short_model(name: str) -> str: + """Friendly menu-bar label, e.g. mlx-community/parakeet-tdt-0.6b-v3 -> parakeet-v3.""" + tail = name.rsplit("/", 1)[-1] + return _LABELS.get(tail, tail) + + if __name__ == "__main__": # ponytail: pure-logic checks only; live model calls are covered by `v2t bench`. assert set(STT) == {"parakeet", "whisper"} assert set(CLEANUP) == {"mlx", "ollama"} + assert short_model("mlx-community/parakeet-tdt-0.6b-v3") == "parakeet-v3" + assert short_model("mlx-community/Qwen3-4B-Instruct-2507-4bit") == "Qwen3-4B" + assert short_model("custom/unknown") == "unknown" assert "filler" in PROMPTS["strict"] and "Keep the original phrasing" in PROMPTS["casual"] for fn in (make_stt, make_cleanup): try: diff --git a/v2t/cli.py b/v2t/cli.py index 7034ed6..20c66dc 100644 --- a/v2t/cli.py +++ b/v2t/cli.py @@ -139,9 +139,16 @@ def cmd_setup(argv: list[str]) -> int: def cmd_status(argv: list[str]) -> int: - """For the SwiftBar plugin: 'off', or '\t\t'.""" + """For the SwiftBar plugin: '\t\t\t' (state is 'off' when not running).""" + from . import backends + s = config.read_status() - print("off" if s is None else f"{s['state']}\t{s['model']}\t{s['mode']}") + cfg = config.load() + state = s["state"] if s else "off" + stt = backends.short_model(cfg.stt_model or backends.STT[cfg.backend].default_model) + cleanup = (backends.short_model(cfg.cleanup_model or backends.CLEANUP[cfg.cleanup_engine].default_model) + if cfg.cleanup_enabled else "off") + print(f"{state}\t{stt}\t{cleanup}\t{cfg.mode}") return 0 From cb333a570e7c90314c9bf4277cdfa525a888486c Mon Sep 17 00:00:00 2001 From: lucharo Date: Mon, 20 Jul 2026 02:11:28 +0100 Subject: [PATCH 09/38] fix: harden v0.3 runtime and packaging --- .github/workflows/check.yml | 24 + .gitignore | 4 + CHANGELOG.md | 8 +- README.md | 53 +- benchmarks/README.md | 18 +- benchmarks/results/GRID.md | 4 +- justfile | 27 +- pixi.lock | 2220 +++++++++++++++++++++-------------- pyproject.toml | 21 +- swiftbar/v2t.5s.sh | 52 +- tests/test_smoke.py | 278 +++++ uv.lock | 12 +- v2t/app.py | 363 ++++-- v2t/backends.py | 48 +- v2t/bench.py | 160 ++- v2t/cli.py | 205 +++- v2t/config.py | 186 ++- v2t/service.py | 159 +++ 18 files changed, 2731 insertions(+), 1111 deletions(-) create mode 100644 .github/workflows/check.yml create mode 100644 tests/test_smoke.py create mode 100644 v2t/service.py diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..7c4d597 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,24 @@ +name: check + +on: + push: + pull_request: + +jobs: + smoke: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + python-version: "3.12" + - run: uv lock --check + - run: uvx ruff check v2t tests + - run: bash -n swiftbar/v2t.5s.sh + - name: Mechanics smoke tests + run: >- + uv run --no-project --python 3.12 + --with 'numpy<2.4' --with scipy --with sounddevice --with loguru + python -m unittest -v tests.test_smoke + - run: uv build + - run: uvx twine check dist/* diff --git a/.gitignore b/.gitignore index 2d43501..c8d0c3e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,7 @@ dist/ !.pixi/config.toml .env + +# Local agent handoff state +.continues-handoff.md +.omo/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 7381369..d0b3ea8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,12 @@ This release turns v2t into a small, modular, MLX-first package. - **Config file** at `~/.v2t/config.toml` (honors `$V2T_HOME` / `$XDG_CONFIG_HOME`), with `v2t config [--init]`. - **Transcription history** — every result + metadata appended to `~/.v2t/history/transcriptions.jsonl`. - **SwiftBar plugin** (`swiftbar/v2t.5s.sh`) — menu-bar toggle, status, open config/history. -- **Benchmark harness** — `v2t bench` writes a per-machine markdown grid (STT RTF + cleanup TTFT/total) with `engine:model` columns. Self-contained STT inputs via macOS `say`. -- **New commands**: `v2t setup`, `v2t status`, `v2t stop`, `v2t config`, `v2t bench`. +- **Optional LaunchAgent** — `v2t service install` keeps one warm process running across the login session; SwiftBar detects and controls it through the same status protocol. +- **Reliable runtime lifecycle** — single-instance locking, honest live states, microphone failure recovery, native permission checks, and safe shutdown while a transcription finishes. +- **Private, lossless local data** — config/history/status use owner-only permissions, temporary audio is always removed, logs no longer include dictated text, and rich clipboard contents survive paste. +- **Reproducible install and release path** — constrained scientific dependencies, bundled SwiftBar installer, clean sdists, smoke-test CI, and validated build/release commands. +- **Contributor benchmark harness** — `just bench` writes a per-machine markdown grid (STT RTF + cleanup TTFT/total) with `engine:model` columns. Optional Whisper stays in the dev dependency group. +- **New commands**: `v2t setup`, `v2t status`, `v2t stop`, `v2t service`, `v2t config`, `v2t swiftbar`. ## 0.2.0 diff --git a/README.md b/README.md index cfbd088..b06f700 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,14 @@ Local, MLX-first voice-to-text. Hold **Right ⌘**, talk, release — it transcribes with [Parakeet](https://huggingface.co/mlx-community/parakeet-tdt-0.6b-v3), cleans the text up with a -small local LLM, and pastes at your cursor. No cloud, no network, no account. +small local LLM, and pastes at your cursor. No cloud or account; after the one-time model download, +it runs without a network connection. Voice-to-text tools like [Wispr Flow](https://wisprflow.ai/), [MacWhisper](https://goodsnooze.gumroad.com/l/macwhisper), and [VoiceInk](https://www.voiceink.app/) are great until the network gets in the way — Wispr Flow [isn't compatible with most VPNs](https://docs.wisprflow.ai/troubleshooting), so behind a corporate -SSL-intercepting gateway (Zscaler and friends) it stalls or fails. Going local makes that whole class -of problem disappear: zero network dependency means the VPN is irrelevant. Speech models are good +SSL-intercepting gateway (Zscaler and friends) it stalls or fails. Once the models are cached, going +local makes that whole class of problem disappear: the VPN is irrelevant. Speech models are good enough now that the basics fit in a small Python package on consumer hardware. > **Heritage:** this started as a single `voice2text.py` under 300 lines — that proof-of-concept is @@ -23,9 +24,9 @@ enough now that the basics fit in a small Python package on consumer hardware. > harness, and a SwiftBar menu-bar toggle. > **Design tenet — be communicative.** An ergonomic tool is an expressive one: every action gets -> immediate, visible feedback. The menu-bar icon tracks live state (off · starting · ready · -> recording · transcribing · cleaning) and repaints the instant anything changes, so you always -> know the tool heard you. +> immediate, visible feedback. The menu-bar icon tracks live state (off · loading · ready · +> recording · transcribing · cleaning · error) and repaints the instant anything changes, so you +> always know whether the tool heard you. ## What you get @@ -35,11 +36,12 @@ enough now that the basics fit in a small Python package on consumer hardware. - **Pastes at cursor**, restoring your previous clipboard. - **One config file** at `~/.v2t/config.toml`, plus a JSONL **history** of every transcription. - **SwiftBar plugin** — menu-bar toggle, status, and quick links to your config/history. -- **Benchmark harness** — `v2t bench` writes a per-machine markdown grid of model speeds. ## Install -Requires **macOS on Apple Silicon** (MLX). Everything's local and bundled — Parakeet transcription and in-process mlx-lm cleanup ship by default, no daemon, nothing else to pull: +Requires **macOS on Apple Silicon** (MLX). Parakeet transcription and in-process mlx-lm cleanup ship +as dependencies, with no daemon. The models download from Hugging Face on first launch, then remain +in the local cache: ```bash uv tool install voice2text # Parakeet STT + in-process Qwen3 cleanup @@ -65,7 +67,7 @@ pip install voice2text && v2t # from source git clone https://github.com/lucharo/voice2text.git && cd voice2text -uv sync && uv run v2t +uv sync --no-dev && uv run v2t # pixi pixi run v2t @@ -85,7 +87,7 @@ v2t setup # guided config: pick models, detect Ollama v2t status # running / idle (used by the SwiftBar plugin) v2t stop # stop a running v2t v2t config # show resolved config + paths (--init writes a template) -v2t bench # benchmark STT + cleanup models on this machine +v2t service install # optional: keep v2t warm and start it at login ``` Hold **Right Command** to record, release to transcribe and paste. @@ -152,12 +154,27 @@ reasoning. The defaults don't. ```bash brew install swiftbar -cp swiftbar/v2t.5s.sh "$HOME/Library/Application Support/SwiftBar/Plugins/" # your plugins folder +v2t swiftbar # install/update the bundled, hackable shell plugin ``` -The menu shows a colored live-state icon (off · starting · ready · recording · transcribing · -cleaning), the active models, a Start/Stop toggle, a **Permissions** submenu, and links to open -your config and transcription history. +The menu shows a colored live-state icon (off · loading · ready · recording · transcribing · +cleaning · error), the active models, a Start/Stop toggle, a **Permissions** submenu, and links to +open your config, transcription history, and log. The installed plugin remains a normal shell file +in SwiftBar's Plugins folder, so it is easy to inspect or change. + +SwiftBar's normal **Start v2t** action already launches one long-running process: Parakeet and the +cleanup model load once, then stay warm for every transcription. To also start that process at login, +install the optional per-user LaunchAgent: + +```bash +v2t service install # install + start ~/Library/LaunchAgents/com.lucharo.voice2text.plist +v2t service status +v2t service uninstall # stop it and return SwiftBar to direct-launch mode +``` + +The service uses the same lock and status files as direct mode, so SwiftBar detects it automatically. +It runs the exact Python executable shown during installation. If macOS lists that executable as a +separate app in Privacy & Security, grant it the same three permissions. **Permissions.** v2t needs three grants, given to the app that *launches* it (your terminal, or SwiftBar if you use "Start v2t"): **Microphone** (record), **Accessibility** + **Input Monitoring** @@ -167,14 +184,14 @@ Microphone grant: v2t says so and opens the Mic pane. ## Models & benchmarks -`v2t bench` writes `benchmarks/results/-.md` — two tables (speech-to-text RTF, and cleanup -TTFT/total), one column per model. Run it on each machine to build a grid. See -[`benchmarks/`](benchmarks/) for the method and defaults. +The contributor-only `just bench` harness writes `~/.v2t/benchmarks/results/-.md` — two +tables (speech-to-text RTF and cleanup TTFT/total), one column per model. It lives in the dev group, +not the installed CLI. See [`benchmarks/`](benchmarks/) for the method and defaults. | | default | why | |---|---|---| | transcription | `parakeet-tdt-0.6b-v3` | fastest on Apple Silicon, multilingual | | cleanup | `Qwen3-4B-Instruct-2507` (mlx-lm) | latest small instruct, non-thinking, no daemon | -> This is **macOS / Apple Silicon-only** by design (MLX, `osascript` paste, `pbcopy`/`pbpaste`, +> This is **macOS / Apple Silicon-only** by design (MLX, `osascript` paste, the native pasteboard, > `nowplaying-cli`, System Settings permission URLs). Fork it for Linux/Windows if you like. diff --git a/benchmarks/README.md b/benchmarks/README.md index 7c78fb7..32158f9 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -3,35 +3,37 @@ Two tables, models-as-columns, **median of N runs per cell**: - **Speech-to-text** — transcription time and real-time factor (RTF = audio length ÷ processing time; higher is faster). Inputs are generated on the fly with macOS `say`, so the benchmark is self-contained. -- **Text cleanup** — time-to-first-token and total time for the Ollama cleanup model. +- **Text cleanup** — time-to-first-token and total time for MLX or Ollama cleanup models. Absolute numbers reflect the machine they ran on. **Compare models within a file, not across machines** — that's the point of one file per host. ## Run it ```bash -v2t bench # both tables, default models -v2t bench --cleanup # cleanup only (runs anywhere Ollama runs — no Apple Silicon needed) -v2t bench --stt # STT only (needs Apple Silicon / MLX) -v2t bench --repeat 5 # more runs per cell +just bench # both tables, default models +just bench --cleanup # cleanup only (default models use MLX) +just bench --cleanup --cleanup-models ollama:qwen3:4b-instruct-2507 # Ollama/Linux +just bench --stt # STT only (needs Apple Silicon / MLX) +just bench --repeat 5 # more runs per cell ``` Override the model lists: ```bash -v2t bench --stt-models \ +just bench --stt-models \ parakeet:mlx-community/parakeet-tdt-0.6b-v3 \ parakeet:mlx-community/parakeet-tdt-0.6b-v2 \ whisper:mlx-community/whisper-large-v3-turbo -v2t bench --cleanup-models \ +just bench --cleanup-models \ mlx:mlx-community/Qwen3-4B-Instruct-2507-4bit \ ollama:qwen3:4b-instruct-2507 ``` Cleanup models are `engine:model` specs (`mlx:…` or `ollama:…`); a model that isn't installed is marked `n/a` rather than aborting the run. Each run writes -`results/-.md`. Commit it. Run on each Mac to build the grid. +`~/.v2t/benchmarks/results/-.md`; copy results into this directory when contributing +to the grid. ## Default models diff --git a/benchmarks/results/GRID.md b/benchmarks/results/GRID.md index d2016f7..54746a9 100644 --- a/benchmarks/results/GRID.md +++ b/benchmarks/results/GRID.md @@ -57,9 +57,9 @@ words, so STT WER is the accuracy that matters; punctuation/casing it fixes anyw ```bash # on each Mac: -v2t bench # both tables -> benchmarks/results/-.md +just bench # both tables -> ~/.v2t/benchmarks/results/-.md # on a Linux box with Ollama: -v2t bench --cleanup # cleanup table only +just bench --cleanup --cleanup-models ollama:qwen3:4b-instruct-2507 ``` Then copy the medium-clip / median-sample numbers from each per-host file into diff --git a/justfile b/justfile index 9530fa2..05938d0 100644 --- a/justfile +++ b/justfile @@ -1,19 +1,30 @@ # Lint with ruff lint: - uv run ruff check --fix v2t/ + uv run ruff check --fix v2t/ tests/ -# Run module self-checks (no models needed) -check: - python3 -m v2t.config && python3 -m v2t.backends && python3 -m v2t.bench +# Fast checks; no models, microphone, or permissions needed +check: lint + uv run python -m unittest -v tests.test_smoke + uv run python -m v2t.config + uv run python -m v2t.backends + bash -n swiftbar/v2t.5s.sh + +# Contributor benchmark harness (the dev group includes optional Whisper) +bench *args: + uv run python -m v2t.bench {{args}} # Build the package -build: +build: check rm -rf dist/ - uv run hatch build + uv build + uvx twine check dist/* # Publish to PyPI (reads ~/.pypirc) publish: uvx twine upload dist/* -# Build and publish -release: build publish +# Build and publish only from a clean checkout +release: + @test -z "$(git status --porcelain --untracked-files=all)" || (echo "working tree must be clean"; exit 1) + just build + just publish diff --git a/pixi.lock b/pixi.lock index 323b62a..bd73645 100644 --- a/pixi.lock +++ b/pixi.lock @@ -1,16 +1,22 @@ -version: 6 +version: 7 +platforms: +- name: osx-arm64 + virtual-packages: + - __unix=0=0 + - __osx=13.0 + - __archspec=0=m1 environments: default: channels: - url: https://conda.anaconda.org/conda-forge/ indexes: - https://pypi.org/simple - options: - pypi-prerelease-mode: if-necessary-or-explicit packages: osx-arm64: - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-hd037594_8.conda - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.11.12-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h78e105d_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-hd037594_8.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-21.1.7-hf598326_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.7.3-haf25636_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-he5f378a_0.conda @@ -22,71 +28,107 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ollama-0.13.2-cpu_h19ea9b3_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.0-h5503f6c_0.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.13.11-hfc2f54d_100_cp313.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.2-h1d1bf99_2.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h892fb3f_3.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h78e105d_0.conda - - pypi: https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl - - pypi: https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/51/c7/b64cae5dba3a1b138d7123ec36bb5ccd39d39939f18454407e5468f4763f/fsspec-2025.12.0-py3-none-any.whl + - pypi: ./ + - pypi: https://files.pythonhosted.org/packages/03/dc/200013a74641f8774664bbcd2346c695c05c2e300ea792adcb40a293eed0/soxr-1.1.0-cp312-abi3-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/7f/8c/c5becfa53234299bc2210ba314eaaae36c2875e0045809b82e40a9544f0c/hf_xet-1.2.0-cp37-abi3-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/71/40/eb2f3a2c09bebf2fc989ba8bf701ce1f56b2f054b51e1a0fcb3e5d23f13a/huggingface_hub-1.2.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/e6/ff/3eba7eb0aed4b6fca37125387cd417e8c458e750621fce56d2c541f67fa8/llvmlite-0.46.0-cp313-cp313-macosx_12_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/fe/a2/078152b45aa8a23949a1b09601d0044f8bb4ab85e909e4475a440c21aaea/mlx-0.29.3-cp313-cp313-macosx_13_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl + - pypi: https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/1b/83/ea581e364ce7b0d41456fb79d6ee0ad482beda61faf0cab20cbd4c63a541/audioop_lts-0.2.2-cp313-abi3-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/20/a2/a3974e3e807c68e23a9d7db66fc38ac54f7ecd2b7a9237042006699a76e1/pyobjc_framework_coretext-12.1-cp313-cp313-macosx_10_13_universal2.whl + - pypi: https://files.pythonhosted.org/packages/2a/2d/d4bf65e47cea8ff2c794a600c4fd1273a7902f268757c531e0ee9f18aa58/pooch-1.9.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/2f/39/714118f8413e0e353436914f2b976665161f1be2b6483ac15a8f61484c14/sounddevice-0.5.3-py3-none-macosx_10_6_x86_64.macosx_10_6_universal2.whl + - pypi: https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/34/ae/e3707f6c1bc6f7aa0df600ba8075bfb8a19252140cd595335be60e25f9ee/standard_sunau-3.13.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/34/db/f9ea1a6844b4fa5dfe2312095cd866a1f724cd0905054ab9d5991778ba50/sentencepiece-0.2.2-cp313-cp313-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/3e/04/5acd7ae280c5f93b6ac5ef6cdec14eef4c8d1cd91d85b3292989c94d96b1/scikit_learn-1.9.0-cp313-cp313-macosx_12_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/40/03/26a383c9e58c213199d1aad1c3d353cfc22d4444ec6d2c0bf8ad02523843/typer-0.27.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/41/95/a00054a006df82bb1b5b8f666ae44a676b259146fadbff90fe654309fefc/mlx_metal-0.29.3-py3-none-macosx_13_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/22/b7/a35232812a2ccfffcb7614ba96a91338551a660a0e9815cee668bf5743f0/mlx_whisper-0.4.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/44/13/7e63cfba8a7452eb756306aa2fd9b37a29a323b672b964b4fdeded9a3f21/scipy-1.16.3-cp313-cp313-macosx_12_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/51/c7/b64cae5dba3a1b138d7123ec36bb5ccd39d39939f18454407e5468f4763f/fsspec-2025.12.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/57/f7/e19e6eff445bec52dde5bed1ebb162925a8e6f988164f1ae4b3475a73680/numba-0.63.1-cp313-cp313-macosx_12_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/59/4f/ac3fa906ae8a375a536b12794128c5efacade9eaa917a35dfd27ce0c7400/pynput-1.8.1-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5f/c3/aeaaf3911d2529614be18d1c8b5496afc185560e76568063d517283318af/huggingface_hub-1.24.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/60/ac/b097a86a1e4a6098f3a79382643ab09d5733d87ccc864877ad1e12b49b70/hf_xet-1.5.2-cp38-abi3-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/63/7a/dfdd6f8c748988427119f75eb860a3cedd858d1aea1fe28f39ad8559ef22/soundfile-0.14.0-py2.py3-none-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/64/71/fbcfa83a1d6a9c6091942d1cfd070962244664b87427a9a49a6897b1b219/msgpack-1.2.1-cp313-cp313-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/6f/67/8d85ca2323233ae3c0365a659c4e52ee1f587b440e4bc577e7d8e4416d0f/transformers-5.14.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/79/fb/f505c95ceddd7027347b067689db71ca80bd5ecc926f913f1a23e65cf09b/numpy-2.3.5-cp313-cp313-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/7a/90/a5c1084d87767d787a6caba615aa50dc587229646308d9420c960cb5e4c0/standard_chunk-3.13.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/7e/16/fbe8e1e185a45042f7cd3a282def5bb8d95bb69ab9e9ef6a5368aa17e426/audioread-3.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/7e/85/a5bfaebfd305ac18b57b0854d74e37e586809061a91fda62f0bd50c8518e/narwhals-2.24.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/8a/a1/8d812e53a5da1687abb10445275d41a8b13adb781bbf7196ddbcf8d88505/lazy_loader-0.5-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/94/35/386550fd60316d1e37eccdda609b074113298f23cef5bddb2049823fe666/dacite-1.9.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl + - pypi: https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/59/4f/ac3fa906ae8a375a536b12794128c5efacade9eaa917a35dfd27ce0c7400/pynput-1.8.1-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/f4/d2/29e5e536adc07bc3d33dd09f3f7cf844bf7b4981820dc2a91dd810f3c782/pyobjc_core-12.1-cp313-cp313-macosx_10_13_universal2.whl - - pypi: https://files.pythonhosted.org/packages/fc/21/79e42ee836f1010f5fe9e97d2817a006736bd287c15a3674c399190a2e77/pyobjc_framework_applicationservices-12.1-cp313-cp313-macosx_10_13_universal2.whl + - pypi: https://files.pythonhosted.org/packages/a3/88/1a3ea5672f4b0a84802ee9891b86743438e7c04eb0b8f8c4e16a42375327/regex-2025.11.3-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/ad/31/0c2e734165abb46215797bd830c4bdcb780b699854b15f2b6240515edcc6/pyobjc_framework_cocoa-12.1-cp313-cp313-macosx_10_13_universal2.whl - - pypi: https://files.pythonhosted.org/packages/20/a2/a3974e3e807c68e23a9d7db66fc38ac54f7ecd2b7a9237042006699a76e1/pyobjc_framework_coretext-12.1-cp313-cp313-macosx_10_13_universal2.whl - - pypi: https://files.pythonhosted.org/packages/ba/2d/e8f495328101898c16c32ac10e7b14b08ff2c443a756a76fd1271915f097/pyobjc_framework_quartz-12.1-cp313-cp313-macosx_10_13_universal2.whl - pypi: https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/a3/88/1a3ea5672f4b0a84802ee9891b86743438e7c04eb0b8f8c4e16a42375327/regex-2025.11.3-cp313-cp313-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/44/13/7e63cfba8a7452eb756306aa2fd9b37a29a323b672b964b4fdeded9a3f21/scipy-1.16.3-cp313-cp313-macosx_12_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b5/ba/c63c5786dfee4c3417094c4b00966e61e4a63efecee22cb7b4c0387dda83/librosa-0.11.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/2f/39/714118f8413e0e353436914f2b976665161f1be2b6483ac15a8f61484c14/sounddevice-0.5.3-py3-none-macosx_10_6_x86_64.macosx_10_6_universal2.whl - - pypi: https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/1f/05/dcf94486d5c5c8d34496abe271ac76c5b785507c8eae71b3708f1ad9b45a/tiktoken-0.12.0-cp313-cp313-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/40/60/71c698b466dd01e65d0e9514b5405faae200c52a76901baf6906856f17e4/torch-2.9.1-cp313-none-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/ba/2d/e8f495328101898c16c32ac10e7b14b08ff2c443a756a76fd1271915f097/pyobjc_framework_quartz-12.1-cp313-cp313-macosx_10_13_universal2.whl + - pypi: https://files.pythonhosted.org/packages/c3/52/5fbb203394cc852334d1575cc020f6bcec768d2265355984dfd361968f36/standard_aifc-3.13.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/5e/dd/5cbf31f402f1cc0ab087c94d4669cfa55bd1e818688b910631e131d74e75/typer_slim-0.20.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl - - pypi: ./ + - pypi: https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e5/53/913099c91d384e115ea078325efd9a0bc1ea3eb3458c694b4596cbd267f2/mlx_lm-0.29.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e6/ff/3eba7eb0aed4b6fca37125387cd417e8c458e750621fce56d2c541f67fa8/llvmlite-0.46.0-cp313-cp313-macosx_12_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/ec/73/6fd0bb9ce84138c3857f12e9de63bc901852975a092d545f18087a204aa2/platformdirs-4.10.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f4/54/09a3501c74f1ef54a36002534661cb89a0b2f098fcad87fe64f515a0dba7/parakeet_mlx-0.5.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f4/d2/29e5e536adc07bc3d33dd09f3f7cf844bf7b4981820dc2a91dd810f3c782/pyobjc_core-12.1-cp313-cp313-macosx_10_13_universal2.whl + - pypi: https://files.pythonhosted.org/packages/f5/b1/fa7c600e7dceae12e9606c7578cbc9ff1e1ed55844883ee5c92205e86226/safetensors-0.8.0-cp310-abi3-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/fc/21/79e42ee836f1010f5fe9e97d2817a006736bd287c15a3674c399190a2e77/pyobjc_framework_applicationservices-12.1-cp313-cp313-macosx_10_13_universal2.whl + - pypi: https://files.pythonhosted.org/packages/fe/a2/078152b45aa8a23949a1b09601d0044f8bb4ab85e909e4475a440c21aaea/mlx-0.29.3-cp313-cp313-macosx_13_0_arm64.whl packages: -- pypi: https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl - name: anyio - version: 4.12.0 - sha256: dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb - requires_dist: - - exceptiongroup>=1.0.2 ; python_full_version < '3.11' - - idna>=2.8 - - typing-extensions>=4.5 ; python_full_version < '3.13' - - trio>=0.32.0 ; python_full_version >= '3.10' and extra == 'trio' - - trio>=0.31.0 ; python_full_version < '3.10' and extra == 'trio' - requires_python: '>=3.9' +- conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.11.12-hbd8a1cb_0.conda + sha256: b986ba796d42c9d3265602bc038f6f5264095702dd546c14bc684e60c385e773 + md5: f0991f0f84902f6b6009b4d2350a83aa + depends: + - __unix + license: ISC + purls: [] + size: 152432 + timestamp: 1762967197890 +- conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda + build_number: 8 + sha256: 210bffe7b121e651419cb196a2a63687b087497595c9be9d20ebe97dd06060a7 + md5: 94305520c52a4aa3f6c2b1ff6008d9f8 + constrains: + - python 3.13.* *_cp313 + license: BSD-3-Clause + license_family: BSD + purls: [] + size: 7002 + timestamp: 1752805902938 +- conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h78e105d_0.conda + sha256: 5aaa366385d716557e365f0a4e9c3fca43ba196872abbbe3d56bb610d131e192 + md5: 4222072737ccff51314b5ece9c7d6f5a + license: LicenseRef-Public-Domain + purls: [] + size: 122968 + timestamp: 1742727099393 - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-hd037594_8.conda sha256: b456200636bd5fecb2bec63f7e0985ad2097cf1b83d60ce0b6968dffa6d02aa1 md5: 58fd217444c2a5701a44244faf518206 @@ -97,332 +139,13 @@ packages: purls: [] size: 125061 timestamp: 1757437486465 -- conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.11.12-hbd8a1cb_0.conda - sha256: b986ba796d42c9d3265602bc038f6f5264095702dd546c14bc684e60c385e773 - md5: f0991f0f84902f6b6009b4d2350a83aa +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-21.1.7-hf598326_0.conda + sha256: 4bdbef0241b52e7a8552e8af7425f0b56d5621dd69df46c816546fefa17d77ab + md5: 0de94f39727c31c0447e408c5a210a56 depends: - - __unix - license: ISC - purls: [] - size: 152432 - timestamp: 1762967197890 -- pypi: https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl - name: certifi - version: 2025.11.12 - sha256: 97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b - requires_python: '>=3.7' -- pypi: https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl - name: cffi - version: 2.0.0 - sha256: 45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca - requires_dist: - - pycparser ; implementation_name != 'PyPy' - requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl - name: charset-normalizer - version: 3.4.4 - sha256: e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794 - requires_python: '>=3.7' -- pypi: https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl - name: click - version: 8.3.1 - sha256: 981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6 - requires_dist: - - colorama ; sys_platform == 'win32' - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl - name: filelock - version: 3.20.0 - sha256: 339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2 - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/51/c7/b64cae5dba3a1b138d7123ec36bb5ccd39d39939f18454407e5468f4763f/fsspec-2025.12.0-py3-none-any.whl - name: fsspec - version: 2025.12.0 - sha256: 8bf1fe301b7d8acfa6e8571e3b1c3d158f909666642431cc78a1b7b4dbc5ec5b - requires_dist: - - adlfs ; extra == 'abfs' - - adlfs ; extra == 'adl' - - pyarrow>=1 ; extra == 'arrow' - - dask ; extra == 'dask' - - distributed ; extra == 'dask' - - pre-commit ; extra == 'dev' - - ruff>=0.5 ; extra == 'dev' - - numpydoc ; extra == 'doc' - - sphinx ; extra == 'doc' - - sphinx-design ; extra == 'doc' - - sphinx-rtd-theme ; extra == 'doc' - - yarl ; extra == 'doc' - - dropbox ; extra == 'dropbox' - - dropboxdrivefs ; extra == 'dropbox' - - requests ; extra == 'dropbox' - - adlfs ; extra == 'full' - - aiohttp!=4.0.0a0,!=4.0.0a1 ; extra == 'full' - - dask ; extra == 'full' - - distributed ; extra == 'full' - - dropbox ; extra == 'full' - - dropboxdrivefs ; extra == 'full' - - fusepy ; extra == 'full' - - gcsfs ; extra == 'full' - - libarchive-c ; extra == 'full' - - ocifs ; extra == 'full' - - panel ; extra == 'full' - - paramiko ; extra == 'full' - - pyarrow>=1 ; extra == 'full' - - pygit2 ; extra == 'full' - - requests ; extra == 'full' - - s3fs ; extra == 'full' - - smbprotocol ; extra == 'full' - - tqdm ; extra == 'full' - - fusepy ; extra == 'fuse' - - gcsfs ; extra == 'gcs' - - pygit2 ; extra == 'git' - - requests ; extra == 'github' - - gcsfs ; extra == 'gs' - - panel ; extra == 'gui' - - pyarrow>=1 ; extra == 'hdfs' - - aiohttp!=4.0.0a0,!=4.0.0a1 ; extra == 'http' - - libarchive-c ; extra == 'libarchive' - - ocifs ; extra == 'oci' - - s3fs ; extra == 's3' - - paramiko ; extra == 'sftp' - - smbprotocol ; extra == 'smb' - - paramiko ; extra == 'ssh' - - aiohttp!=4.0.0a0,!=4.0.0a1 ; extra == 'test' - - numpy ; extra == 'test' - - pytest ; extra == 'test' - - pytest-asyncio!=0.22.0 ; extra == 'test' - - pytest-benchmark ; extra == 'test' - - pytest-cov ; extra == 'test' - - pytest-mock ; extra == 'test' - - pytest-recording ; extra == 'test' - - pytest-rerunfailures ; extra == 'test' - - requests ; extra == 'test' - - aiobotocore>=2.5.4,<3.0.0 ; extra == 'test-downstream' - - dask[dataframe,test] ; extra == 'test-downstream' - - moto[server]>4,<5 ; extra == 'test-downstream' - - pytest-timeout ; extra == 'test-downstream' - - xarray ; extra == 'test-downstream' - - adlfs ; extra == 'test-full' - - aiohttp!=4.0.0a0,!=4.0.0a1 ; extra == 'test-full' - - cloudpickle ; extra == 'test-full' - - dask ; extra == 'test-full' - - distributed ; extra == 'test-full' - - dropbox ; extra == 'test-full' - - dropboxdrivefs ; extra == 'test-full' - - fastparquet ; extra == 'test-full' - - fusepy ; extra == 'test-full' - - gcsfs ; extra == 'test-full' - - jinja2 ; extra == 'test-full' - - kerchunk ; extra == 'test-full' - - libarchive-c ; extra == 'test-full' - - lz4 ; extra == 'test-full' - - notebook ; extra == 'test-full' - - numpy ; extra == 'test-full' - - ocifs ; extra == 'test-full' - - pandas ; extra == 'test-full' - - panel ; extra == 'test-full' - - paramiko ; extra == 'test-full' - - pyarrow ; extra == 'test-full' - - pyarrow>=1 ; extra == 'test-full' - - pyftpdlib ; extra == 'test-full' - - pygit2 ; extra == 'test-full' - - pytest ; extra == 'test-full' - - pytest-asyncio!=0.22.0 ; extra == 'test-full' - - pytest-benchmark ; extra == 'test-full' - - pytest-cov ; extra == 'test-full' - - pytest-mock ; extra == 'test-full' - - pytest-recording ; extra == 'test-full' - - pytest-rerunfailures ; extra == 'test-full' - - python-snappy ; extra == 'test-full' - - requests ; extra == 'test-full' - - smbprotocol ; extra == 'test-full' - - tqdm ; extra == 'test-full' - - urllib3 ; extra == 'test-full' - - zarr ; extra == 'test-full' - - zstandard ; python_full_version < '3.14' and extra == 'test-full' - - tqdm ; extra == 'tqdm' - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl - name: h11 - version: 0.16.0 - sha256: 63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 - requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/7f/8c/c5becfa53234299bc2210ba314eaaae36c2875e0045809b82e40a9544f0c/hf_xet-1.2.0-cp37-abi3-macosx_11_0_arm64.whl - name: hf-xet - version: 1.2.0 - sha256: 27df617a076420d8845bea087f59303da8be17ed7ec0cd7ee3b9b9f579dff0e4 - requires_dist: - - pytest ; extra == 'tests' - requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl - name: httpcore - version: 1.0.9 - sha256: 2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 - requires_dist: - - certifi - - h11>=0.16 - - anyio>=4.0,<5.0 ; extra == 'asyncio' - - h2>=3,<5 ; extra == 'http2' - - socksio==1.* ; extra == 'socks' - - trio>=0.22.0,<1.0 ; extra == 'trio' - requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl - name: httpx - version: 0.28.1 - sha256: d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad - requires_dist: - - anyio - - certifi - - httpcore==1.* - - idna - - brotli ; platform_python_implementation == 'CPython' and extra == 'brotli' - - brotlicffi ; platform_python_implementation != 'CPython' and extra == 'brotli' - - click==8.* ; extra == 'cli' - - pygments==2.* ; extra == 'cli' - - rich>=10,<14 ; extra == 'cli' - - h2>=3,<5 ; extra == 'http2' - - socksio==1.* ; extra == 'socks' - - zstandard>=0.18.0 ; extra == 'zstd' - requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/71/40/eb2f3a2c09bebf2fc989ba8bf701ce1f56b2f054b51e1a0fcb3e5d23f13a/huggingface_hub-1.2.2-py3-none-any.whl - name: huggingface-hub - version: 1.2.2 - sha256: 0f55d7d22058fbf8b29d8095aeee80a7b695aa764f906a21e886c1f87223718f - requires_dist: - - filelock - - fsspec>=2023.5.0 - - hf-xet>=1.2.0,<2.0.0 ; platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64' - - httpx>=0.23.0,<1 - - packaging>=20.9 - - pyyaml>=5.1 - - shellingham - - tqdm>=4.42.1 - - typer-slim - - typing-extensions>=3.7.4.3 - - authlib>=1.3.2 ; extra == 'oauth' - - fastapi ; extra == 'oauth' - - httpx ; extra == 'oauth' - - itsdangerous ; extra == 'oauth' - - torch ; extra == 'torch' - - safetensors[torch] ; extra == 'torch' - - toml ; extra == 'fastai' - - fastai>=2.4 ; extra == 'fastai' - - fastcore>=1.3.27 ; extra == 'fastai' - - hf-xet>=1.1.3,<2.0.0 ; extra == 'hf-xet' - - mcp>=1.8.0 ; extra == 'mcp' - - authlib>=1.3.2 ; extra == 'testing' - - fastapi ; extra == 'testing' - - httpx ; extra == 'testing' - - itsdangerous ; extra == 'testing' - - jedi ; extra == 'testing' - - jinja2 ; extra == 'testing' - - pytest>=8.4.2 ; extra == 'testing' - - pytest-cov ; extra == 'testing' - - pytest-env ; extra == 'testing' - - pytest-xdist ; extra == 'testing' - - pytest-vcr ; extra == 'testing' - - pytest-asyncio ; extra == 'testing' - - pytest-rerunfailures<16.0 ; extra == 'testing' - - pytest-mock ; extra == 'testing' - - urllib3<2.0 ; extra == 'testing' - - soundfile ; extra == 'testing' - - pillow ; extra == 'testing' - - numpy ; extra == 'testing' - - fastapi ; extra == 'testing' - - typing-extensions>=4.8.0 ; extra == 'typing' - - types-pyyaml ; extra == 'typing' - - types-simplejson ; extra == 'typing' - - types-toml ; extra == 'typing' - - types-tqdm ; extra == 'typing' - - types-urllib3 ; extra == 'typing' - - ruff>=0.9.0 ; extra == 'quality' - - mypy==1.15.0 ; extra == 'quality' - - libcst>=1.4.0 ; extra == 'quality' - - ty ; extra == 'quality' - - authlib>=1.3.2 ; extra == 'all' - - fastapi ; extra == 'all' - - httpx ; extra == 'all' - - itsdangerous ; extra == 'all' - - jedi ; extra == 'all' - - jinja2 ; extra == 'all' - - pytest>=8.4.2 ; extra == 'all' - - pytest-cov ; extra == 'all' - - pytest-env ; extra == 'all' - - pytest-xdist ; extra == 'all' - - pytest-vcr ; extra == 'all' - - pytest-asyncio ; extra == 'all' - - pytest-rerunfailures<16.0 ; extra == 'all' - - pytest-mock ; extra == 'all' - - urllib3<2.0 ; extra == 'all' - - soundfile ; extra == 'all' - - pillow ; extra == 'all' - - numpy ; extra == 'all' - - fastapi ; extra == 'all' - - ruff>=0.9.0 ; extra == 'all' - - mypy==1.15.0 ; extra == 'all' - - libcst>=1.4.0 ; extra == 'all' - - ty ; extra == 'all' - - typing-extensions>=4.8.0 ; extra == 'all' - - types-pyyaml ; extra == 'all' - - types-simplejson ; extra == 'all' - - types-toml ; extra == 'all' - - types-tqdm ; extra == 'all' - - types-urllib3 ; extra == 'all' - - authlib>=1.3.2 ; extra == 'dev' - - fastapi ; extra == 'dev' - - httpx ; extra == 'dev' - - itsdangerous ; extra == 'dev' - - jedi ; extra == 'dev' - - jinja2 ; extra == 'dev' - - pytest>=8.4.2 ; extra == 'dev' - - pytest-cov ; extra == 'dev' - - pytest-env ; extra == 'dev' - - pytest-xdist ; extra == 'dev' - - pytest-vcr ; extra == 'dev' - - pytest-asyncio ; extra == 'dev' - - pytest-rerunfailures<16.0 ; extra == 'dev' - - pytest-mock ; extra == 'dev' - - urllib3<2.0 ; extra == 'dev' - - soundfile ; extra == 'dev' - - pillow ; extra == 'dev' - - numpy ; extra == 'dev' - - fastapi ; extra == 'dev' - - ruff>=0.9.0 ; extra == 'dev' - - mypy==1.15.0 ; extra == 'dev' - - libcst>=1.4.0 ; extra == 'dev' - - ty ; extra == 'dev' - - typing-extensions>=4.8.0 ; extra == 'dev' - - types-pyyaml ; extra == 'dev' - - types-simplejson ; extra == 'dev' - - types-toml ; extra == 'dev' - - types-tqdm ; extra == 'dev' - - types-urllib3 ; extra == 'dev' - requires_python: '>=3.9.0' -- pypi: https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl - name: idna - version: '3.11' - sha256: 771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea - requires_dist: - - ruff>=0.6.2 ; extra == 'all' - - mypy>=1.11.2 ; extra == 'all' - - pytest>=8.3.2 ; extra == 'all' - - flake8>=7.1.1 ; extra == 'all' - requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl - name: jinja2 - version: 3.1.6 - sha256: 85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67 - requires_dist: - - markupsafe>=2.0 - - babel>=2.7 ; extra == 'i18n' - requires_python: '>=3.7' -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-21.1.7-hf598326_0.conda - sha256: 4bdbef0241b52e7a8552e8af7425f0b56d5621dd69df46c816546fefa17d77ab - md5: 0de94f39727c31c0447e408c5a210a56 - depends: - - __osx >=11.0 - license: Apache-2.0 WITH LLVM-exception - license_family: Apache + - __osx >=11.0 + license: Apache-2.0 WITH LLVM-exception + license_family: Apache purls: [] size: 568715 timestamp: 1764676451068 @@ -491,102 +214,6 @@ packages: purls: [] size: 46438 timestamp: 1727963202283 -- pypi: https://files.pythonhosted.org/packages/e6/ff/3eba7eb0aed4b6fca37125387cd417e8c458e750621fce56d2c541f67fa8/llvmlite-0.46.0-cp313-cp313-macosx_12_0_arm64.whl - name: llvmlite - version: 0.46.0 - sha256: 30b60892d034bc560e0ec6654737aaa74e5ca327bd8114d82136aa071d611172 - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl - name: loguru - version: 0.7.3 - sha256: 31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c - requires_dist: - - colorama>=0.3.4 ; sys_platform == 'win32' - - aiocontextvars>=0.2.0 ; python_full_version < '3.7' - - win32-setctime>=1.0.0 ; sys_platform == 'win32' - - pre-commit==4.0.1 ; python_full_version >= '3.9' and extra == 'dev' - - tox==3.27.1 ; python_full_version < '3.8' and extra == 'dev' - - tox==4.23.2 ; python_full_version >= '3.8' and extra == 'dev' - - pytest==6.1.2 ; python_full_version < '3.8' and extra == 'dev' - - pytest==8.3.2 ; python_full_version >= '3.8' and extra == 'dev' - - pytest-cov==2.12.1 ; python_full_version < '3.8' and extra == 'dev' - - pytest-cov==5.0.0 ; python_full_version == '3.8.*' and extra == 'dev' - - pytest-cov==6.0.0 ; python_full_version >= '3.9' and extra == 'dev' - - pytest-mypy-plugins==1.9.3 ; python_full_version >= '3.6' and python_full_version < '3.8' and extra == 'dev' - - pytest-mypy-plugins==3.1.0 ; python_full_version >= '3.8' and extra == 'dev' - - colorama==0.4.5 ; python_full_version < '3.8' and extra == 'dev' - - colorama==0.4.6 ; python_full_version >= '3.8' and extra == 'dev' - - freezegun==1.1.0 ; python_full_version < '3.8' and extra == 'dev' - - freezegun==1.5.0 ; python_full_version >= '3.8' and extra == 'dev' - - exceptiongroup==1.1.3 ; python_full_version >= '3.7' and python_full_version < '3.11' and extra == 'dev' - - mypy==0.910 ; python_full_version < '3.6' and extra == 'dev' - - mypy==0.971 ; python_full_version == '3.6.*' and extra == 'dev' - - mypy==1.4.1 ; python_full_version == '3.7.*' and extra == 'dev' - - mypy==1.13.0 ; python_full_version >= '3.8' and extra == 'dev' - - sphinx==8.1.3 ; python_full_version >= '3.11' and extra == 'dev' - - sphinx-rtd-theme==3.0.2 ; python_full_version >= '3.11' and extra == 'dev' - - myst-parser==4.0.0 ; python_full_version >= '3.11' and extra == 'dev' - - build==1.2.2 ; python_full_version >= '3.11' and extra == 'dev' - - twine==6.0.1 ; python_full_version >= '3.11' and extra == 'dev' - requires_python: '>=3.5,<4.0' -- pypi: https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl - name: markupsafe - version: 3.0.3 - sha256: 116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219 - requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/fe/a2/078152b45aa8a23949a1b09601d0044f8bb4ab85e909e4475a440c21aaea/mlx-0.29.3-cp313-cp313-macosx_13_0_arm64.whl - name: mlx - version: 0.29.3 - sha256: d59eccf6a1e1e131becc5a3910504507862da3a4e9b7bd9e73a625515d767844 - requires_dist: - - mlx-metal==0.29.3 ; sys_platform == 'darwin' - - nanobind==2.4.0 ; extra == 'dev' - - numpy ; extra == 'dev' - - pre-commit ; extra == 'dev' - - setuptools>=80 ; extra == 'dev' - - torch ; extra == 'dev' - - typing-extensions ; extra == 'dev' - - mlx-cuda==0.29.3 ; sys_platform == 'linux' and extra == 'cuda' - - mlx-cpu==0.29.3 ; sys_platform == 'linux' and extra == 'cpu' - requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/41/95/a00054a006df82bb1b5b8f666ae44a676b259146fadbff90fe654309fefc/mlx_metal-0.29.3-py3-none-macosx_13_0_arm64.whl - name: mlx-metal - version: 0.29.3 - sha256: 27b5a4d905202a71e84d9fd559ea0236813f6f960ef494e5cafe9c45df4c9d7c - requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/22/b7/a35232812a2ccfffcb7614ba96a91338551a660a0e9815cee668bf5743f0/mlx_whisper-0.4.3-py3-none-any.whl - name: mlx-whisper - version: 0.4.3 - sha256: 6b82b6597a994643a3e5496c7bc229a672e5ca308458455bfe276e76ae024489 - requires_dist: - - mlx>=0.11 - - numba - - numpy - - torch - - tqdm - - more-itertools - - tiktoken - - huggingface-hub - - scipy - requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl - name: more-itertools - version: 10.8.0 - sha256: 52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b - requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl - name: mpmath - version: 1.3.0 - sha256: a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c - requires_dist: - - pytest>=4.6 ; extra == 'develop' - - pycodestyle ; extra == 'develop' - - pytest-cov ; extra == 'develop' - - codecov ; extra == 'develop' - - wheel ; extra == 'develop' - - sphinx ; extra == 'docs' - - gmpy2>=2.1.0a4 ; platform_python_implementation != 'PyPy' and extra == 'gmpy' - - pytest>=4.6 ; extra == 'tests' - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.5-h5e97a16_3.conda sha256: 2827ada40e8d9ca69a153a45f7fd14f32b2ead7045d3bbb5d10964898fe65733 md5: 068d497125e4bf8a66bf707254fff5ae @@ -596,62 +223,6 @@ packages: purls: [] size: 797030 timestamp: 1738196177597 -- pypi: https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl - name: networkx - version: 3.6.1 - sha256: d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762 - requires_dist: - - asv ; extra == 'benchmarking' - - virtualenv ; extra == 'benchmarking' - - numpy>=1.25 ; extra == 'default' - - scipy>=1.11.2 ; extra == 'default' - - matplotlib>=3.8 ; extra == 'default' - - pandas>=2.0 ; extra == 'default' - - pre-commit>=4.1 ; extra == 'developer' - - mypy>=1.15 ; extra == 'developer' - - sphinx>=8.0 ; extra == 'doc' - - pydata-sphinx-theme>=0.16 ; extra == 'doc' - - sphinx-gallery>=0.18 ; extra == 'doc' - - numpydoc>=1.8.0 ; extra == 'doc' - - pillow>=10 ; extra == 'doc' - - texext>=0.6.7 ; extra == 'doc' - - myst-nb>=1.1 ; extra == 'doc' - - intersphinx-registry ; extra == 'doc' - - osmnx>=2.0.0 ; extra == 'example' - - momepy>=0.7.2 ; extra == 'example' - - contextily>=1.6 ; extra == 'example' - - seaborn>=0.13 ; extra == 'example' - - cairocffi>=1.7 ; extra == 'example' - - igraph>=0.11 ; extra == 'example' - - scikit-learn>=1.5 ; extra == 'example' - - iplotx>=0.9.0 ; extra == 'example' - - lxml>=4.6 ; extra == 'extra' - - pygraphviz>=1.14 ; extra == 'extra' - - pydot>=3.0.1 ; extra == 'extra' - - sympy>=1.10 ; extra == 'extra' - - build>=0.10 ; extra == 'release' - - twine>=4.0 ; extra == 'release' - - wheel>=0.40 ; extra == 'release' - - changelist==0.5 ; extra == 'release' - - pytest>=7.2 ; extra == 'test' - - pytest-cov>=4.0 ; extra == 'test' - - pytest-xdist>=3.0 ; extra == 'test' - - pytest-mpl ; extra == 'test-extras' - - pytest-randomly ; extra == 'test-extras' - requires_python: '>=3.11,!=3.14.1' -- pypi: https://files.pythonhosted.org/packages/57/f7/e19e6eff445bec52dde5bed1ebb162925a8e6f988164f1ae4b3475a73680/numba-0.63.1-cp313-cp313-macosx_12_0_arm64.whl - name: numba - version: 0.63.1 - sha256: 0bd4fd820ef7442dcc07da184c3f54bb41d2bdb7b35bacf3448e73d081f730dc - requires_dist: - - llvmlite>=0.46.0.dev0,<0.47 - - numpy>=1.22,<2.4 - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/79/fb/f505c95ceddd7027347b067689db71ca80bd5ecc926f913f1a23e65cf09b/numpy-2.3.5-cp313-cp313-macosx_11_0_arm64.whl - name: numpy - version: 2.3.5 - sha256: aa5bc7c5d59d831d9773d1170acac7893ce3a5e130540605770ade83280e7188 - requires_python: '>=3.11' - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ollama-0.13.2-cpu_h19ea9b3_0.conda sha256: 2d71d0c04a1a6a856e14147373cccc99beaa4fcc7d18f2afb9548fe1103f772b md5: 70dc3952b80d824be4f86057e37f660b @@ -673,19 +244,502 @@ packages: purls: [] size: 3108371 timestamp: 1762839712322 -- pypi: https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl - name: packaging - version: '25.0' - sha256: 29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 - requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl - name: pycparser - version: '2.23' - sha256: e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934 - requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/59/4f/ac3fa906ae8a375a536b12794128c5efacade9eaa917a35dfd27ce0c7400/pynput-1.8.1-py2.py3-none-any.whl - name: pynput - version: 1.8.1 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.13.11-hfc2f54d_100_cp313.conda + build_number: 100 + sha256: c476f4e9b6d97c46b496b442878924868a54e5727251549ebfc82027aa52af68 + md5: 18a8c69608151098a8fb75eea64cc266 + depends: + - __osx >=11.0 + - bzip2 >=1.0.8,<2.0a0 + - libexpat >=2.7.3,<3.0a0 + - libffi >=3.5.2,<3.6.0a0 + - liblzma >=5.8.1,<6.0a0 + - libmpdec >=4.0.0,<5.0a0 + - libsqlite >=3.51.1,<4.0a0 + - libzlib >=1.3.1,<2.0a0 + - ncurses >=6.5,<7.0a0 + - openssl >=3.5.4,<4.0a0 + - python_abi 3.13.* *_cp313 + - readline >=8.2,<9.0a0 + - tk >=8.6.13,<8.7.0a0 + - tzdata + license: Python-2.0 + purls: [] + size: 12920650 + timestamp: 1765020887340 + python_site_packages_path: lib/python3.13/site-packages +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.2-h1d1bf99_2.conda + sha256: 7db04684d3904f6151eff8673270922d31da1eea7fa73254d01c437f49702e34 + md5: 63ef3f6e6d6d5c589e64f11263dc5676 + depends: + - ncurses >=6.5,<7.0a0 + license: GPL-3.0-only + license_family: GPL + purls: [] + size: 252359 + timestamp: 1740379663071 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h892fb3f_3.conda + sha256: ad0c67cb03c163a109820dc9ecf77faf6ec7150e942d1e8bb13e5d39dc058ab7 + md5: a73d54a5abba6543cb2f0af1bfbd6851 + depends: + - __osx >=11.0 + - libzlib >=1.3.1,<2.0a0 + license: TCL + license_family: BSD + purls: [] + size: 3125484 + timestamp: 1763055028377 +- pypi: ./ + name: voice2text + requires_dist: + - sounddevice + - numpy<2.4 + - scipy + - pynput + - loguru + - parakeet-mlx + - mlx-lm + - mlx-whisper ; extra == 'whisper' + requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/03/dc/200013a74641f8774664bbcd2346c695c05c2e300ea792adcb40a293eed0/soxr-1.1.0-cp312-abi3-macosx_11_0_arm64.whl + name: soxr + version: 1.1.0 + sha256: d6a7ad82b8d5f3fcc04b1d2ca055562b96af571e1d4fa7c6c61d0fb509ac43b4 + requires_dist: + - numpy + - sphinx ; extra == 'docs' + - sphinx-book-theme ; extra == 'docs' + - myst-parser ; extra == 'docs' + - linkify-it-py ; extra == 'docs' + - pytest ; extra == 'test' + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl + name: h11 + version: 0.16.0 + sha256: 63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl + name: decorator + version: 5.3.1 + sha256: f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl + name: loguru + version: 0.7.3 + sha256: 31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c + requires_dist: + - colorama>=0.3.4 ; sys_platform == 'win32' + - aiocontextvars>=0.2.0 ; python_full_version < '3.7' + - win32-setctime>=1.0.0 ; sys_platform == 'win32' + - pre-commit==4.0.1 ; python_full_version >= '3.9' and extra == 'dev' + - tox==3.27.1 ; python_full_version < '3.8' and extra == 'dev' + - tox==4.23.2 ; python_full_version >= '3.8' and extra == 'dev' + - pytest==6.1.2 ; python_full_version < '3.8' and extra == 'dev' + - pytest==8.3.2 ; python_full_version >= '3.8' and extra == 'dev' + - pytest-cov==2.12.1 ; python_full_version < '3.8' and extra == 'dev' + - pytest-cov==5.0.0 ; python_full_version == '3.8.*' and extra == 'dev' + - pytest-cov==6.0.0 ; python_full_version >= '3.9' and extra == 'dev' + - pytest-mypy-plugins==1.9.3 ; python_full_version >= '3.6' and python_full_version < '3.8' and extra == 'dev' + - pytest-mypy-plugins==3.1.0 ; python_full_version >= '3.8' and extra == 'dev' + - colorama==0.4.5 ; python_full_version < '3.8' and extra == 'dev' + - colorama==0.4.6 ; python_full_version >= '3.8' and extra == 'dev' + - freezegun==1.1.0 ; python_full_version < '3.8' and extra == 'dev' + - freezegun==1.5.0 ; python_full_version >= '3.8' and extra == 'dev' + - exceptiongroup==1.1.3 ; python_full_version >= '3.7' and python_full_version < '3.11' and extra == 'dev' + - mypy==0.910 ; python_full_version < '3.6' and extra == 'dev' + - mypy==0.971 ; python_full_version == '3.6.*' and extra == 'dev' + - mypy==1.4.1 ; python_full_version == '3.7.*' and extra == 'dev' + - mypy==1.13.0 ; python_full_version >= '3.8' and extra == 'dev' + - sphinx==8.1.3 ; python_full_version >= '3.11' and extra == 'dev' + - sphinx-rtd-theme==3.0.2 ; python_full_version >= '3.11' and extra == 'dev' + - myst-parser==4.0.0 ; python_full_version >= '3.11' and extra == 'dev' + - build==1.2.2 ; python_full_version >= '3.11' and extra == 'dev' + - twine==6.0.1 ; python_full_version >= '3.11' and extra == 'dev' + requires_python: '>=3.5,<4.0' +- pypi: https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl + name: idna + version: '3.11' + sha256: 771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea + requires_dist: + - ruff>=0.6.2 ; extra == 'all' + - mypy>=1.11.2 ; extra == 'all' + - pytest>=8.3.2 ; extra == 'all' + - flake8>=7.1.1 ; extra == 'all' + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl + name: protobuf + version: 7.35.1 + sha256: 24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6 + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl + name: typing-extensions + version: 4.15.0 + sha256: f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548 + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/1b/83/ea581e364ce7b0d41456fb79d6ee0ad482beda61faf0cab20cbd4c63a541/audioop_lts-0.2.2-cp313-abi3-macosx_11_0_arm64.whl + name: audioop-lts + version: 0.2.2 + sha256: 9a13dc409f2564de15dd68be65b462ba0dde01b19663720c68c1140c782d1d75 + requires_python: '>=3.13' +- pypi: https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl + name: annotated-doc + version: 0.0.4 + sha256: 571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320 + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl + name: requests + version: 2.32.5 + sha256: 2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6 + requires_dist: + - charset-normalizer>=2,<4 + - idna>=2.5,<4 + - urllib3>=1.21.1,<3 + - certifi>=2017.4.17 + - pysocks>=1.5.6,!=1.5.7 ; extra == 'socks' + - chardet>=3.0.2,<6 ; extra == 'use-chardet-on-py3' + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl + name: packaging + version: '25.0' + sha256: 29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/20/a2/a3974e3e807c68e23a9d7db66fc38ac54f7ecd2b7a9237042006699a76e1/pyobjc_framework_coretext-12.1-cp313-cp313-macosx_10_13_universal2.whl + name: pyobjc-framework-coretext + version: '12.1' + sha256: 7cbb2c28580e6704ce10b9a991ccd9563a22b3a75f67c36cf612544bd8b21b5f + requires_dist: + - pyobjc-core>=12.1 + - pyobjc-framework-cocoa>=12.1 + - pyobjc-framework-quartz>=12.1 + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/2a/2d/d4bf65e47cea8ff2c794a600c4fd1273a7902f268757c531e0ee9f18aa58/pooch-1.9.0-py3-none-any.whl + name: pooch + version: 1.9.0 + sha256: f265597baa9f760d25ceb29d0beb8186c243d6607b0f60b83ecf14078dbc703b + requires_dist: + - platformdirs>=2.5.0 + - packaging>=20.0 + - requests>=2.19.0 + - tqdm>=4.41.0,<5.0.0 ; extra == 'progress' + - paramiko>=2.7.0 ; extra == 'sftp' + - xxhash>=1.4.3 ; extra == 'xxhash' + - pytest-httpserver ; extra == 'test' + - pytest-localftpserver ; extra == 'test' + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl + name: httpx + version: 0.28.1 + sha256: d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad + requires_dist: + - anyio + - certifi + - httpcore==1.* + - idna + - brotli ; platform_python_implementation == 'CPython' and extra == 'brotli' + - brotlicffi ; platform_python_implementation != 'CPython' and extra == 'brotli' + - click==8.* ; extra == 'cli' + - pygments==2.* ; extra == 'cli' + - rich>=10,<14 ; extra == 'cli' + - h2>=3,<5 ; extra == 'http2' + - socksio==1.* ; extra == 'socks' + - zstandard>=0.18.0 ; extra == 'zstd' + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl + name: tokenizers + version: 0.22.2 + sha256: 1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001 + requires_dist: + - huggingface-hub>=0.16.4,<2.0 + - pytest ; extra == 'testing' + - pytest-asyncio ; extra == 'testing' + - requests ; extra == 'testing' + - numpy ; extra == 'testing' + - datasets ; extra == 'testing' + - ruff ; extra == 'testing' + - ty ; extra == 'testing' + - sphinx ; extra == 'docs' + - sphinx-rtd-theme ; extra == 'docs' + - setuptools-rust ; extra == 'docs' + - tokenizers[testing] ; extra == 'dev' + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/2f/39/714118f8413e0e353436914f2b976665161f1be2b6483ac15a8f61484c14/sounddevice-0.5.3-py3-none-macosx_10_6_x86_64.macosx_10_6_universal2.whl + name: sounddevice + version: 0.5.3 + sha256: 278dc4451fff70934a176df048b77d80d7ce1623a6ec9db8b34b806f3112f9c2 + requires_dist: + - cffi>=1.0 + - numpy ; extra == 'numpy' + requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl + name: threadpoolctl + version: 3.6.0 + sha256: 43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/34/ae/e3707f6c1bc6f7aa0df600ba8075bfb8a19252140cd595335be60e25f9ee/standard_sunau-3.13.0-py3-none-any.whl + name: standard-sunau + version: 3.13.0 + sha256: 53af624a9529c41062f4c2fd33837f297f3baa196b0cfceffea6555654602622 + requires_dist: + - audioop-lts ; python_full_version >= '3.13' +- pypi: https://files.pythonhosted.org/packages/34/db/f9ea1a6844b4fa5dfe2312095cd866a1f724cd0905054ab9d5991778ba50/sentencepiece-0.2.2-cp313-cp313-macosx_11_0_arm64.whl + name: sentencepiece + version: 0.2.2 + sha256: 201a8e0f55501a76e08dbf2c54bc45f4642b379271e89c667d517bfbc2191f2a + requires_dist: + - numpy ; extra == 'numpy' + - protobuf ; extra == 'protobuf' + - pytest ; extra == 'test' + - numpy ; extra == 'test' + - protobuf ; extra == 'test' + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/3e/04/5acd7ae280c5f93b6ac5ef6cdec14eef4c8d1cd91d85b3292989c94d96b1/scikit_learn-1.9.0-cp313-cp313-macosx_12_0_arm64.whl + name: scikit-learn + version: 1.9.0 + sha256: 5b934c45c252844a91d69fda3a34cff5e7307e1db10d77cb10a3980312c74713 + requires_dist: + - numpy>=1.24.1 + - scipy>=1.10.0 + - joblib>=1.4.0 + - narwhals>=2.0.1 + - threadpoolctl>=3.5.0 + - numpy>=1.24.1 ; extra == 'build' + - scipy>=1.10.0 ; extra == 'build' + - cython>=3.1.2 ; extra == 'build' + - meson-python>=0.17.1 ; extra == 'build' + - numpy>=1.24.1 ; extra == 'install' + - scipy>=1.10.0 ; extra == 'install' + - joblib>=1.4.0 ; extra == 'install' + - narwhals>=2.0.1 ; extra == 'install' + - threadpoolctl>=3.5.0 ; extra == 'install' + - matplotlib>=3.6.1 ; extra == 'benchmark' + - pandas>=1.5.0 ; extra == 'benchmark' + - memory-profiler>=0.57.0 ; extra == 'benchmark' + - matplotlib>=3.6.1 ; extra == 'docs' + - scikit-image>=0.22.0 ; extra == 'docs' + - pandas>=1.5.0 ; extra == 'docs' + - rich>=14.1.0 ; extra == 'docs' + - seaborn>=0.13.0 ; extra == 'docs' + - memory-profiler>=0.57.0 ; extra == 'docs' + - sphinx>=7.3.7 ; extra == 'docs' + - sphinx-copybutton>=0.5.2 ; extra == 'docs' + - sphinx-gallery>=0.17.1 ; extra == 'docs' + - numpydoc>=1.2.0 ; extra == 'docs' + - pillow>=12.1.1 ; extra == 'docs' + - pooch>=1.8.0 ; extra == 'docs' + - sphinx-prompt>=1.4.0 ; extra == 'docs' + - sphinxext-opengraph>=0.9.1 ; extra == 'docs' + - plotly>=5.22.0 ; extra == 'docs' + - polars>=0.20.30 ; extra == 'docs' + - sphinx-design>=0.6.0 ; extra == 'docs' + - sphinxcontrib-sass>=0.3.4 ; extra == 'docs' + - pydata-sphinx-theme>=0.15.3 ; extra == 'docs' + - sphinx-remove-toctrees>=1.0.0.post1 ; extra == 'docs' + - towncrier>=24.8.0 ; extra == 'docs' + - matplotlib>=3.6.1 ; extra == 'examples' + - scikit-image>=0.22.0 ; extra == 'examples' + - pandas>=1.5.0 ; extra == 'examples' + - rich>=14.1.0 ; extra == 'examples' + - seaborn>=0.13.0 ; extra == 'examples' + - pooch>=1.8.0 ; extra == 'examples' + - plotly>=5.22.0 ; extra == 'examples' + - matplotlib>=3.6.1 ; extra == 'tests' + - pandas>=1.5.0 ; extra == 'tests' + - rich>=14.1.0 ; extra == 'tests' + - pytest>=7.1.2 ; extra == 'tests' + - pytest-cov>=2.9.0 ; extra == 'tests' + - ruff>=0.12.2 ; extra == 'tests' + - mypy>=1.15 ; extra == 'tests' + - pyamg>=5.0.0 ; extra == 'tests' + - polars>=0.20.30 ; extra == 'tests' + - pyarrow>=13.0.0 ; extra == 'tests' + - numpydoc>=1.2.0 ; extra == 'tests' + - pooch>=1.8.0 ; extra == 'tests' + - conda-lock==3.0.1 ; extra == 'maintenance' + requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/40/03/26a383c9e58c213199d1aad1c3d353cfc22d4444ec6d2c0bf8ad02523843/typer-0.27.0-py3-none-any.whl + name: typer + version: 0.27.0 + sha256: 6f4b27631e47f077871b7dc30e933ec0131c1390fbe0e387ea5574b5bac9ccf1 + requires_dist: + - shellingham>=1.3.0 + - rich>=13.8.0 + - annotated-doc>=0.0.2 + - colorama ; sys_platform == 'win32' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/41/95/a00054a006df82bb1b5b8f666ae44a676b259146fadbff90fe654309fefc/mlx_metal-0.29.3-py3-none-macosx_13_0_arm64.whl + name: mlx-metal + version: 0.29.3 + sha256: 27b5a4d905202a71e84d9fd559ea0236813f6f960ef494e5cafe9c45df4c9d7c + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/44/13/7e63cfba8a7452eb756306aa2fd9b37a29a323b672b964b4fdeded9a3f21/scipy-1.16.3-cp313-cp313-macosx_12_0_arm64.whl + name: scipy + version: 1.16.3 + sha256: 16b8bc35a4cc24db80a0ec836a9286d0e31b2503cb2fd7ff7fb0e0374a97081d + requires_dist: + - numpy>=1.25.2,<2.6 + - pytest>=8.0.0 ; extra == 'test' + - pytest-cov ; extra == 'test' + - pytest-timeout ; extra == 'test' + - pytest-xdist ; extra == 'test' + - asv ; extra == 'test' + - mpmath ; extra == 'test' + - gmpy2 ; extra == 'test' + - threadpoolctl ; extra == 'test' + - scikit-umfpack ; extra == 'test' + - pooch ; extra == 'test' + - hypothesis>=6.30 ; extra == 'test' + - array-api-strict>=2.3.1 ; extra == 'test' + - cython ; extra == 'test' + - meson ; extra == 'test' + - ninja ; sys_platform != 'emscripten' and extra == 'test' + - sphinx>=5.0.0,<8.2.0 ; extra == 'doc' + - intersphinx-registry ; extra == 'doc' + - pydata-sphinx-theme>=0.15.2 ; extra == 'doc' + - sphinx-copybutton ; extra == 'doc' + - sphinx-design>=0.4.0 ; extra == 'doc' + - matplotlib>=3.5 ; extra == 'doc' + - numpydoc ; extra == 'doc' + - jupytext ; extra == 'doc' + - myst-nb>=1.2.0 ; extra == 'doc' + - pooch ; extra == 'doc' + - jupyterlite-sphinx>=0.19.1 ; extra == 'doc' + - jupyterlite-pyodide-kernel ; extra == 'doc' + - linkify-it-py ; extra == 'doc' + - mypy==1.10.0 ; extra == 'dev' + - typing-extensions ; extra == 'dev' + - types-psutil ; extra == 'dev' + - pycodestyle ; extra == 'dev' + - ruff>=0.0.292 ; extra == 'dev' + - cython-lint>=0.12.2 ; extra == 'dev' + - rich-click ; extra == 'dev' + - doit>=0.36.0 ; extra == 'dev' + - pydevtool ; extra == 'dev' + requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl + name: cffi + version: 2.0.0 + sha256: 45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca + requires_dist: + - pycparser ; implementation_name != 'PyPy' + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/51/c7/b64cae5dba3a1b138d7123ec36bb5ccd39d39939f18454407e5468f4763f/fsspec-2025.12.0-py3-none-any.whl + name: fsspec + version: 2025.12.0 + sha256: 8bf1fe301b7d8acfa6e8571e3b1c3d158f909666642431cc78a1b7b4dbc5ec5b + requires_dist: + - adlfs ; extra == 'abfs' + - adlfs ; extra == 'adl' + - pyarrow>=1 ; extra == 'arrow' + - dask ; extra == 'dask' + - distributed ; extra == 'dask' + - pre-commit ; extra == 'dev' + - ruff>=0.5 ; extra == 'dev' + - numpydoc ; extra == 'doc' + - sphinx ; extra == 'doc' + - sphinx-design ; extra == 'doc' + - sphinx-rtd-theme ; extra == 'doc' + - yarl ; extra == 'doc' + - dropbox ; extra == 'dropbox' + - dropboxdrivefs ; extra == 'dropbox' + - requests ; extra == 'dropbox' + - adlfs ; extra == 'full' + - aiohttp!=4.0.0a0,!=4.0.0a1 ; extra == 'full' + - dask ; extra == 'full' + - distributed ; extra == 'full' + - dropbox ; extra == 'full' + - dropboxdrivefs ; extra == 'full' + - fusepy ; extra == 'full' + - gcsfs ; extra == 'full' + - libarchive-c ; extra == 'full' + - ocifs ; extra == 'full' + - panel ; extra == 'full' + - paramiko ; extra == 'full' + - pyarrow>=1 ; extra == 'full' + - pygit2 ; extra == 'full' + - requests ; extra == 'full' + - s3fs ; extra == 'full' + - smbprotocol ; extra == 'full' + - tqdm ; extra == 'full' + - fusepy ; extra == 'fuse' + - gcsfs ; extra == 'gcs' + - pygit2 ; extra == 'git' + - requests ; extra == 'github' + - gcsfs ; extra == 'gs' + - panel ; extra == 'gui' + - pyarrow>=1 ; extra == 'hdfs' + - aiohttp!=4.0.0a0,!=4.0.0a1 ; extra == 'http' + - libarchive-c ; extra == 'libarchive' + - ocifs ; extra == 'oci' + - s3fs ; extra == 's3' + - paramiko ; extra == 'sftp' + - smbprotocol ; extra == 'smb' + - paramiko ; extra == 'ssh' + - aiohttp!=4.0.0a0,!=4.0.0a1 ; extra == 'test' + - numpy ; extra == 'test' + - pytest ; extra == 'test' + - pytest-asyncio!=0.22.0 ; extra == 'test' + - pytest-benchmark ; extra == 'test' + - pytest-cov ; extra == 'test' + - pytest-mock ; extra == 'test' + - pytest-recording ; extra == 'test' + - pytest-rerunfailures ; extra == 'test' + - requests ; extra == 'test' + - aiobotocore>=2.5.4,<3.0.0 ; extra == 'test-downstream' + - dask[dataframe,test] ; extra == 'test-downstream' + - moto[server]>4,<5 ; extra == 'test-downstream' + - pytest-timeout ; extra == 'test-downstream' + - xarray ; extra == 'test-downstream' + - adlfs ; extra == 'test-full' + - aiohttp!=4.0.0a0,!=4.0.0a1 ; extra == 'test-full' + - cloudpickle ; extra == 'test-full' + - dask ; extra == 'test-full' + - distributed ; extra == 'test-full' + - dropbox ; extra == 'test-full' + - dropboxdrivefs ; extra == 'test-full' + - fastparquet ; extra == 'test-full' + - fusepy ; extra == 'test-full' + - gcsfs ; extra == 'test-full' + - jinja2 ; extra == 'test-full' + - kerchunk ; extra == 'test-full' + - libarchive-c ; extra == 'test-full' + - lz4 ; extra == 'test-full' + - notebook ; extra == 'test-full' + - numpy ; extra == 'test-full' + - ocifs ; extra == 'test-full' + - pandas ; extra == 'test-full' + - panel ; extra == 'test-full' + - paramiko ; extra == 'test-full' + - pyarrow ; extra == 'test-full' + - pyarrow>=1 ; extra == 'test-full' + - pyftpdlib ; extra == 'test-full' + - pygit2 ; extra == 'test-full' + - pytest ; extra == 'test-full' + - pytest-asyncio!=0.22.0 ; extra == 'test-full' + - pytest-benchmark ; extra == 'test-full' + - pytest-cov ; extra == 'test-full' + - pytest-mock ; extra == 'test-full' + - pytest-recording ; extra == 'test-full' + - pytest-rerunfailures ; extra == 'test-full' + - python-snappy ; extra == 'test-full' + - requests ; extra == 'test-full' + - smbprotocol ; extra == 'test-full' + - tqdm ; extra == 'test-full' + - urllib3 ; extra == 'test-full' + - zarr ; extra == 'test-full' + - zstandard ; python_full_version < '3.14' and extra == 'test-full' + - tqdm ; extra == 'tqdm' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/57/f7/e19e6eff445bec52dde5bed1ebb162925a8e6f988164f1ae4b3475a73680/numba-0.63.1-cp313-cp313-macosx_12_0_arm64.whl + name: numba + version: 0.63.1 + sha256: 0bd4fd820ef7442dcc07da184c3f54bb41d2bdb7b35bacf3448e73d081f730dc + requires_dist: + - llvmlite>=0.46.0.dev0,<0.47 + - numpy>=1.22,<2.4 + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/59/4f/ac3fa906ae8a375a536b12794128c5efacade9eaa917a35dfd27ce0c7400/pynput-1.8.1-py2.py3-none-any.whl + name: pynput + version: 1.8.1 sha256: 42dfcf27404459ca16ca889c8fb8ffe42a9fe54f722fd1a3e130728e59e768d2 requires_dist: - six @@ -694,21 +748,558 @@ packages: - enum34 ; python_full_version == '2.7.*' - pyobjc-framework-applicationservices>=8.0 ; sys_platform == 'darwin' - pyobjc-framework-quartz>=8.0 ; sys_platform == 'darwin' -- pypi: https://files.pythonhosted.org/packages/f4/d2/29e5e536adc07bc3d33dd09f3f7cf844bf7b4981820dc2a91dd810f3c782/pyobjc_core-12.1-cp313-cp313-macosx_10_13_universal2.whl - name: pyobjc-core - version: '12.1' - sha256: 01c0cf500596f03e21c23aef9b5f326b9fb1f8f118cf0d8b66749b6cf4cbb37a +- pypi: https://files.pythonhosted.org/packages/5f/c3/aeaaf3911d2529614be18d1c8b5496afc185560e76568063d517283318af/huggingface_hub-1.24.0-py3-none-any.whl + name: huggingface-hub + version: 1.24.0 + sha256: 6ed4120a84a6beec900640aa7e346bd766a6b7341e41526fef5dc8bd81fb7d59 + requires_dist: + - click>=8.4.2,<9.0.0 + - filelock>=3.10.0 + - fsspec>=2023.5.0 + - hf-xet>=1.5.1,<2.0.0 ; platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64' + - httpx>=0.23.0,<1 + - packaging>=20.9 + - pyyaml>=5.1 + - tqdm>=4.42.1 + - typing-extensions>=4.1.0 + - authlib>=1.3.2 ; extra == 'oauth' + - fastapi ; extra == 'oauth' + - httpx ; extra == 'oauth' + - itsdangerous ; extra == 'oauth' + - torch ; extra == 'torch' + - safetensors[torch] ; extra == 'torch' + - toml ; extra == 'fastai' + - fastai>=2.4 ; extra == 'fastai' + - fastcore>=1.3.27 ; extra == 'fastai' + - hf-xet>=1.5.1,<2.0.0 ; extra == 'hf-xet' + - mcp>=1.8.0 ; extra == 'mcp' + - authlib>=1.3.2 ; extra == 'testing' + - fastapi ; extra == 'testing' + - httpx ; extra == 'testing' + - itsdangerous ; extra == 'testing' + - jedi ; extra == 'testing' + - jinja2 ; extra == 'testing' + - pytest>=8.4.2 ; extra == 'testing' + - pytest-cov ; extra == 'testing' + - pytest-env ; extra == 'testing' + - pytest-xdist ; extra == 'testing' + - pytest-vcr ; extra == 'testing' + - pytest-asyncio ; extra == 'testing' + - pytest-rerunfailures<16.0 ; extra == 'testing' + - pytest-mock ; extra == 'testing' + - urllib3<2.0 ; extra == 'testing' + - soundfile ; extra == 'testing' + - pillow ; extra == 'testing' + - numpy ; extra == 'testing' + - duckdb ; extra == 'testing' + - fastapi ; extra == 'testing' + - gradio>=5.0.0 ; extra == 'gradio' + - requests ; extra == 'gradio' + - typing-extensions>=4.8.0 ; extra == 'typing' + - types-pyyaml ; extra == 'typing' + - types-simplejson ; extra == 'typing' + - types-toml ; extra == 'typing' + - types-tqdm ; extra == 'typing' + - types-urllib3 ; extra == 'typing' + - ruff>=0.9.0 ; extra == 'quality' + - mypy==1.15.0 ; extra == 'quality' + - libcst>=1.4.0 ; extra == 'quality' + - ty ; extra == 'quality' + - authlib>=1.3.2 ; extra == 'all' + - fastapi ; extra == 'all' + - httpx ; extra == 'all' + - itsdangerous ; extra == 'all' + - jedi ; extra == 'all' + - jinja2 ; extra == 'all' + - pytest>=8.4.2 ; extra == 'all' + - pytest-cov ; extra == 'all' + - pytest-env ; extra == 'all' + - pytest-xdist ; extra == 'all' + - pytest-vcr ; extra == 'all' + - pytest-asyncio ; extra == 'all' + - pytest-rerunfailures<16.0 ; extra == 'all' + - pytest-mock ; extra == 'all' + - urllib3<2.0 ; extra == 'all' + - soundfile ; extra == 'all' + - pillow ; extra == 'all' + - numpy ; extra == 'all' + - duckdb ; extra == 'all' + - fastapi ; extra == 'all' + - ruff>=0.9.0 ; extra == 'all' + - mypy==1.15.0 ; extra == 'all' + - libcst>=1.4.0 ; extra == 'all' + - ty ; extra == 'all' + - typing-extensions>=4.8.0 ; extra == 'all' + - types-pyyaml ; extra == 'all' + - types-simplejson ; extra == 'all' + - types-toml ; extra == 'all' + - types-tqdm ; extra == 'all' + - types-urllib3 ; extra == 'all' + - authlib>=1.3.2 ; extra == 'dev' + - fastapi ; extra == 'dev' + - httpx ; extra == 'dev' + - itsdangerous ; extra == 'dev' + - jedi ; extra == 'dev' + - jinja2 ; extra == 'dev' + - pytest>=8.4.2 ; extra == 'dev' + - pytest-cov ; extra == 'dev' + - pytest-env ; extra == 'dev' + - pytest-xdist ; extra == 'dev' + - pytest-vcr ; extra == 'dev' + - pytest-asyncio ; extra == 'dev' + - pytest-rerunfailures<16.0 ; extra == 'dev' + - pytest-mock ; extra == 'dev' + - urllib3<2.0 ; extra == 'dev' + - soundfile ; extra == 'dev' + - pillow ; extra == 'dev' + - numpy ; extra == 'dev' + - duckdb ; extra == 'dev' + - fastapi ; extra == 'dev' + - ruff>=0.9.0 ; extra == 'dev' + - mypy==1.15.0 ; extra == 'dev' + - libcst>=1.4.0 ; extra == 'dev' + - ty ; extra == 'dev' + - typing-extensions>=4.8.0 ; extra == 'dev' + - types-pyyaml ; extra == 'dev' + - types-simplejson ; extra == 'dev' + - types-toml ; extra == 'dev' + - types-tqdm ; extra == 'dev' + - types-urllib3 ; extra == 'dev' + requires_python: '>=3.10.0' +- pypi: https://files.pythonhosted.org/packages/60/ac/b097a86a1e4a6098f3a79382643ab09d5733d87ccc864877ad1e12b49b70/hf_xet-1.5.2-cp38-abi3-macosx_11_0_arm64.whl + name: hf-xet + version: 1.5.2 + sha256: 045f84440c55cdeb659cf1a1dd48c77bcd0d2e93632e2fea8f2c3bdee79f38ed + requires_dist: + - pytest ; extra == 'tests' + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl + name: jinja2 + version: 3.1.6 + sha256: 85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67 + requires_dist: + - markupsafe>=2.0 + - babel>=2.7 ; extra == 'i18n' + requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/63/7a/dfdd6f8c748988427119f75eb860a3cedd858d1aea1fe28f39ad8559ef22/soundfile-0.14.0-py2.py3-none-macosx_11_0_arm64.whl + name: soundfile + version: 0.14.0 + sha256: d828d35a059626da52f1415b5faee610aeab393319cb3fc4a9aef47b619fc14c + requires_dist: + - cffi>=1.0 + - numpy + - typing-extensions requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/fc/21/79e42ee836f1010f5fe9e97d2817a006736bd287c15a3674c399190a2e77/pyobjc_framework_applicationservices-12.1-cp313-cp313-macosx_10_13_universal2.whl - name: pyobjc-framework-applicationservices - version: '12.1' - sha256: bd1f4dbb38234a24ae6819f5e22485cf7dd3dd4074ff3bf9a9fdb4c01a3b4a38 +- pypi: https://files.pythonhosted.org/packages/64/71/fbcfa83a1d6a9c6091942d1cfd070962244664b87427a9a49a6897b1b219/msgpack-1.2.1-cp313-cp313-macosx_11_0_arm64.whl + name: msgpack + version: 1.2.1 + sha256: 491cc39455ca765fad51fb451bf2915eb2cf41192ab5801ce8d67c1d614fe056 + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl + name: urllib3 + version: 2.6.2 + sha256: ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd requires_dist: - - pyobjc-core>=12.1 - - pyobjc-framework-cocoa>=12.1 - - pyobjc-framework-quartz>=12.1 - - pyobjc-framework-coretext>=12.1 + - brotli>=1.2.0 ; platform_python_implementation == 'CPython' and extra == 'brotli' + - brotlicffi>=1.2.0.0 ; platform_python_implementation != 'CPython' and extra == 'brotli' + - h2>=4,<5 ; extra == 'h2' + - pysocks>=1.5.6,!=1.5.7,<2.0 ; extra == 'socks' + - backports-zstd>=1.0.0 ; python_full_version < '3.14' and extra == 'zstd' + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/6f/67/8d85ca2323233ae3c0365a659c4e52ee1f587b440e4bc577e7d8e4416d0f/transformers-5.14.1-py3-none-any.whl + name: transformers + version: 5.14.1 + sha256: 9db974c4079ede2d1a3ea7ca5a240df33f2cc26fc2b36ba64c5f2a4f43b6e725 + requires_dist: + - huggingface-hub>=1.5.0,<2.0 + - numpy>=1.17 + - packaging>=20.0 + - pyyaml>=5.1 + - regex>=2025.10.22 + - tokenizers>=0.22.0,<=0.23.0 + - typer + - safetensors>=0.8.0 + - tqdm>=4.60 + - torch>=2.4 ; extra == 'torch' + - accelerate>=1.1.0 ; extra == 'torch' + - torchvision ; extra == 'vision' + - pillow>=10.0.1,<=15.0 ; extra == 'vision' + - torchaudio ; extra == 'audio' + - librosa ; extra == 'audio' + - pyctcdecode>=0.4.0 ; extra == 'audio' + - phonemizer ; extra == 'audio' + - av ; extra == 'video' + - timm>=1.0.23 ; extra == 'timm' + - datasets>=2.15.0 ; extra == 'quality' + - ruff==0.14.10 ; extra == 'quality' + - gitpython<3.1.19 ; extra == 'quality' + - urllib3<2.0.0 ; extra == 'quality' + - libcst ; extra == 'quality' + - rich ; extra == 'quality' + - ty==0.0.20 ; extra == 'quality' + - tomli ; extra == 'quality' + - transformers-mlinter==0.1.2 ; extra == 'quality' + - hf-doc-builder ; extra == 'docs' + - kernels>=0.15.2,<0.16 ; extra == 'kernels' + - sentencepiece>=0.1.91,!=0.1.92 ; extra == 'sentencepiece' + - protobuf ; extra == 'sentencepiece' + - tiktoken ; extra == 'tiktoken' + - blobfile ; extra == 'tiktoken' + - mistral-common[image]>=1.11.5 ; extra == 'mistral-common' + - jinja2>=3.1.0 ; extra == 'chat-template' + - scikit-learn ; extra == 'sklearn' + - accelerate>=1.1.0 ; extra == 'accelerate' + - faiss-cpu ; extra == 'retrieval' + - datasets>=2.15.0 ; extra == 'retrieval' + - sagemaker>=2.31.0 ; extra == 'sagemaker' + - deepspeed>=0.9.3 ; extra == 'deepspeed' + - accelerate>=1.1.0 ; extra == 'deepspeed' + - optuna ; extra == 'optuna' + - kernels>=0.15.2,<0.16 ; extra == 'integrations' + - optuna ; extra == 'integrations' + - codecarbon>=2.8.1 ; extra == 'integrations' + - ray[tune]>=2.7.0 ; extra == 'integrations' + - ray[tune]>=2.7.0 ; extra == 'ray' + - codecarbon>=2.8.1 ; extra == 'codecarbon' + - openai>=1.98.0 ; extra == 'serving' + - pydantic>=2 ; extra == 'serving' + - uvicorn ; extra == 'serving' + - fastapi ; extra == 'serving' + - starlette ; extra == 'serving' + - rich ; extra == 'serving' + - torch>=2.4 ; extra == 'serving' + - accelerate>=1.1.0 ; extra == 'serving' + - num2words ; extra == 'num2words' + - optimum-benchmark>=0.3.0 ; extra == 'benchmark' + - fugashi>=1.0 ; extra == 'ja' + - ipadic>=1.0.0,<2.0 ; extra == 'ja' + - unidic-lite>=1.0.7 ; extra == 'ja' + - unidic>=1.0.2 ; extra == 'ja' + - rhoknp>=1.1.0,<1.3.1 ; extra == 'ja' + - sudachipy>=0.6.6 ; extra == 'ja' + - sudachidict-core>=20220729 ; extra == 'ja' + - pytest>=7.2.0,<9.0.0 ; extra == 'testing' + - pytest-asyncio>=1.2.0 ; extra == 'testing' + - pytest-random-order ; extra == 'testing' + - pytest-rich ; extra == 'testing' + - pytest-xdist ; extra == 'testing' + - pytest-order ; extra == 'testing' + - pytest-rerunfailures<16.0 ; extra == 'testing' + - pytest-timeout ; extra == 'testing' + - pytest-env ; extra == 'testing' + - timeout-decorator ; extra == 'testing' + - parameterized>=0.9 ; extra == 'testing' + - psutil ; extra == 'testing' + - dill<0.3.5 ; extra == 'testing' + - evaluate>=0.4.6 ; extra == 'testing' + - rouge-score!=0.0.7,!=0.0.8,!=0.1,!=0.1.1 ; extra == 'testing' + - nltk<=3.8.1 ; extra == 'testing' + - sacremoses ; extra == 'testing' + - rjieba ; extra == 'testing' + - beautifulsoup4 ; extra == 'testing' + - tensorboard ; extra == 'testing' + - sacrebleu>=1.4.12,<2.0.0 ; extra == 'testing' + - filelock ; extra == 'testing' + - hf-doc-builder ; extra == 'testing' + - datasets>=2.15.0 ; extra == 'testing' + - ruff==0.14.10 ; extra == 'testing' + - gitpython<3.1.19 ; extra == 'testing' + - urllib3<2.0.0 ; extra == 'testing' + - libcst ; extra == 'testing' + - rich ; extra == 'testing' + - ty==0.0.20 ; extra == 'testing' + - tomli ; extra == 'testing' + - transformers-mlinter==0.1.2 ; extra == 'testing' + - faiss-cpu ; extra == 'testing' + - datasets>=2.15.0 ; extra == 'testing' + - sentencepiece>=0.1.91,!=0.1.92 ; extra == 'testing' + - protobuf ; extra == 'testing' + - openai>=1.98.0 ; extra == 'testing' + - pydantic>=2 ; extra == 'testing' + - uvicorn ; extra == 'testing' + - fastapi ; extra == 'testing' + - starlette ; extra == 'testing' + - rich ; extra == 'testing' + - torch>=2.4 ; extra == 'testing' + - accelerate>=1.1.0 ; extra == 'testing' + - mistral-common[image]>=1.11.5 ; extra == 'testing' + - deepspeed>=0.9.3 ; extra == 'deepspeed-testing' + - accelerate>=1.1.0 ; extra == 'deepspeed-testing' + - pytest>=7.2.0,<9.0.0 ; extra == 'deepspeed-testing' + - pytest-asyncio>=1.2.0 ; extra == 'deepspeed-testing' + - pytest-random-order ; extra == 'deepspeed-testing' + - pytest-rich ; extra == 'deepspeed-testing' + - pytest-xdist ; extra == 'deepspeed-testing' + - pytest-order ; extra == 'deepspeed-testing' + - pytest-rerunfailures<16.0 ; extra == 'deepspeed-testing' + - pytest-timeout ; extra == 'deepspeed-testing' + - pytest-env ; extra == 'deepspeed-testing' + - timeout-decorator ; extra == 'deepspeed-testing' + - parameterized>=0.9 ; extra == 'deepspeed-testing' + - psutil ; extra == 'deepspeed-testing' + - dill<0.3.5 ; extra == 'deepspeed-testing' + - evaluate>=0.4.6 ; extra == 'deepspeed-testing' + - rouge-score!=0.0.7,!=0.0.8,!=0.1,!=0.1.1 ; extra == 'deepspeed-testing' + - nltk<=3.8.1 ; extra == 'deepspeed-testing' + - sacremoses ; extra == 'deepspeed-testing' + - rjieba ; extra == 'deepspeed-testing' + - beautifulsoup4 ; extra == 'deepspeed-testing' + - tensorboard ; extra == 'deepspeed-testing' + - sacrebleu>=1.4.12,<2.0.0 ; extra == 'deepspeed-testing' + - filelock ; extra == 'deepspeed-testing' + - hf-doc-builder ; extra == 'deepspeed-testing' + - datasets>=2.15.0 ; extra == 'deepspeed-testing' + - ruff==0.14.10 ; extra == 'deepspeed-testing' + - gitpython<3.1.19 ; extra == 'deepspeed-testing' + - urllib3<2.0.0 ; extra == 'deepspeed-testing' + - libcst ; extra == 'deepspeed-testing' + - rich ; extra == 'deepspeed-testing' + - ty==0.0.20 ; extra == 'deepspeed-testing' + - tomli ; extra == 'deepspeed-testing' + - transformers-mlinter==0.1.2 ; extra == 'deepspeed-testing' + - faiss-cpu ; extra == 'deepspeed-testing' + - datasets>=2.15.0 ; extra == 'deepspeed-testing' + - sentencepiece>=0.1.91,!=0.1.92 ; extra == 'deepspeed-testing' + - protobuf ; extra == 'deepspeed-testing' + - openai>=1.98.0 ; extra == 'deepspeed-testing' + - pydantic>=2 ; extra == 'deepspeed-testing' + - uvicorn ; extra == 'deepspeed-testing' + - fastapi ; extra == 'deepspeed-testing' + - starlette ; extra == 'deepspeed-testing' + - rich ; extra == 'deepspeed-testing' + - torch>=2.4 ; extra == 'deepspeed-testing' + - accelerate>=1.1.0 ; extra == 'deepspeed-testing' + - mistral-common[image]>=1.11.5 ; extra == 'deepspeed-testing' + - optuna ; extra == 'deepspeed-testing' + - sentencepiece>=0.1.91,!=0.1.92 ; extra == 'deepspeed-testing' + - protobuf ; extra == 'deepspeed-testing' + - torch>=2.4 ; extra == 'all' + - accelerate>=1.1.0 ; extra == 'all' + - torchvision ; extra == 'all' + - pillow>=10.0.1,<=15.0 ; extra == 'all' + - torchaudio ; extra == 'all' + - librosa ; extra == 'all' + - pyctcdecode>=0.4.0 ; extra == 'all' + - phonemizer ; extra == 'all' + - av ; extra == 'all' + - kernels>=0.15.2,<0.16 ; extra == 'all' + - timm>=1.0.23 ; extra == 'all' + - sentencepiece>=0.1.91,!=0.1.92 ; extra == 'all' + - protobuf ; extra == 'all' + - tiktoken ; extra == 'all' + - blobfile ; extra == 'all' + - jinja2>=3.1.0 ; extra == 'all' + - num2words ; extra == 'all' + - mistral-common[image]>=1.11.5 ; extra == 'all' + - torch>=2.4 ; extra == 'dev' + - accelerate>=1.1.0 ; extra == 'dev' + - torchvision ; extra == 'dev' + - pillow>=10.0.1,<=15.0 ; extra == 'dev' + - torchaudio ; extra == 'dev' + - librosa ; extra == 'dev' + - pyctcdecode>=0.4.0 ; extra == 'dev' + - phonemizer ; extra == 'dev' + - av ; extra == 'dev' + - kernels>=0.15.2,<0.16 ; extra == 'dev' + - timm>=1.0.23 ; extra == 'dev' + - sentencepiece>=0.1.91,!=0.1.92 ; extra == 'dev' + - protobuf ; extra == 'dev' + - tiktoken ; extra == 'dev' + - blobfile ; extra == 'dev' + - jinja2>=3.1.0 ; extra == 'dev' + - num2words ; extra == 'dev' + - mistral-common[image]>=1.11.5 ; extra == 'dev' + - pytest>=7.2.0,<9.0.0 ; extra == 'dev' + - pytest-asyncio>=1.2.0 ; extra == 'dev' + - pytest-random-order ; extra == 'dev' + - pytest-rich ; extra == 'dev' + - pytest-xdist ; extra == 'dev' + - pytest-order ; extra == 'dev' + - pytest-rerunfailures<16.0 ; extra == 'dev' + - pytest-timeout ; extra == 'dev' + - pytest-env ; extra == 'dev' + - timeout-decorator ; extra == 'dev' + - parameterized>=0.9 ; extra == 'dev' + - psutil ; extra == 'dev' + - dill<0.3.5 ; extra == 'dev' + - evaluate>=0.4.6 ; extra == 'dev' + - rouge-score!=0.0.7,!=0.0.8,!=0.1,!=0.1.1 ; extra == 'dev' + - nltk<=3.8.1 ; extra == 'dev' + - sacremoses ; extra == 'dev' + - rjieba ; extra == 'dev' + - beautifulsoup4 ; extra == 'dev' + - tensorboard ; extra == 'dev' + - sacrebleu>=1.4.12,<2.0.0 ; extra == 'dev' + - filelock ; extra == 'dev' + - hf-doc-builder ; extra == 'dev' + - datasets>=2.15.0 ; extra == 'dev' + - ruff==0.14.10 ; extra == 'dev' + - gitpython<3.1.19 ; extra == 'dev' + - urllib3<2.0.0 ; extra == 'dev' + - libcst ; extra == 'dev' + - rich ; extra == 'dev' + - ty==0.0.20 ; extra == 'dev' + - tomli ; extra == 'dev' + - transformers-mlinter==0.1.2 ; extra == 'dev' + - faiss-cpu ; extra == 'dev' + - datasets>=2.15.0 ; extra == 'dev' + - sentencepiece>=0.1.91,!=0.1.92 ; extra == 'dev' + - protobuf ; extra == 'dev' + - openai>=1.98.0 ; extra == 'dev' + - pydantic>=2 ; extra == 'dev' + - uvicorn ; extra == 'dev' + - fastapi ; extra == 'dev' + - starlette ; extra == 'dev' + - rich ; extra == 'dev' + - torch>=2.4 ; extra == 'dev' + - accelerate>=1.1.0 ; extra == 'dev' + - mistral-common[image]>=1.11.5 ; extra == 'dev' + - fugashi>=1.0 ; extra == 'dev' + - ipadic>=1.0.0,<2.0 ; extra == 'dev' + - unidic-lite>=1.0.7 ; extra == 'dev' + - unidic>=1.0.2 ; extra == 'dev' + - rhoknp>=1.1.0,<1.3.1 ; extra == 'dev' + - sudachipy>=0.6.6 ; extra == 'dev' + - sudachidict-core>=20220729 ; extra == 'dev' + - scikit-learn ; extra == 'dev' + requires_python: '>=3.10.0' +- pypi: https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl + name: certifi + version: 2025.11.12 + sha256: 97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b + requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl + name: filelock + version: 3.20.0 + sha256: 339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2 + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/79/fb/f505c95ceddd7027347b067689db71ca80bd5ecc926f913f1a23e65cf09b/numpy-2.3.5-cp313-cp313-macosx_11_0_arm64.whl + name: numpy + version: 2.3.5 + sha256: aa5bc7c5d59d831d9773d1170acac7893ce3a5e130540605770ade83280e7188 + requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/7a/90/a5c1084d87767d787a6caba615aa50dc587229646308d9420c960cb5e4c0/standard_chunk-3.13.0-py3-none-any.whl + name: standard-chunk + version: 3.13.0 + sha256: 17880a26c285189c644bd5bd8f8ed2bdb795d216e3293e6dbe55bbd848e2982c +- pypi: https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl + name: joblib + version: 1.5.3 + sha256: 5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713 + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/7e/16/fbe8e1e185a45042f7cd3a282def5bb8d95bb69ab9e9ef6a5368aa17e426/audioread-3.1.0-py3-none-any.whl + name: audioread + version: 3.1.0 + sha256: b30d1df6c5d3de5dcef0fb0e256f6ea17bdcf5f979408df0297d8a408e2971b4 + requires_dist: + - pygobject>=3.54.2,<4.0.0 ; extra == 'gi' + - pymad[mad]>=0.11.3,<0.12.0 ; extra == 'mad' + - pytest>=8.4.2 ; extra == 'test' + - pytest-cov>=7.0.0 ; extra == 'test' + - standard-aifc ; python_full_version >= '3.13' + - standard-sunau ; python_full_version >= '3.13' + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/7e/85/a5bfaebfd305ac18b57b0854d74e37e586809061a91fda62f0bd50c8518e/narwhals-2.24.0-py3-none-any.whl + name: narwhals + version: 2.24.0 + sha256: 42fdedf44e5b2ca7505630d45b4ac3058f38d8485cba9fe1652ca23152df7489 + requires_dist: + - cudf-cu12>=24.10.0 ; sys_platform == 'linux' and extra == 'cudf' + - dask[dataframe]>=2024.8 ; extra == 'dask' + - duckdb>=1.1 ; extra == 'duckdb' + - ibis-framework>=6.0.0 ; extra == 'ibis' + - packaging>=21.3 ; extra == 'ibis' + - pyarrow-hotfix>=0.7 ; extra == 'ibis' + - modin>=0.22.0 ; extra == 'modin' + - pandas>=1.3.4 ; extra == 'pandas' + - polars>=0.20.4 ; extra == 'polars' + - pyarrow>=13.0.0 ; extra == 'pyarrow' + - pyspark>=3.5.0 ; extra == 'pyspark' + - pyspark[connect]>=3.5.0 ; extra == 'pyspark-connect' + - narwhals[duckdb] ; extra == 'sql' + - sqlparse>=0.5.5 ; extra == 'sql' + - sqlframe>=3.22.0,!=3.39.3 ; extra == 'sqlframe' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl + name: httpcore + version: 1.0.9 + sha256: 2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 + requires_dist: + - certifi + - h11>=0.16 + - anyio>=4.0,<5.0 ; extra == 'asyncio' + - h2>=3,<5 ; extra == 'http2' + - socksio==1.* ; extra == 'socks' + - trio>=0.22.0,<1.0 ; extra == 'trio' + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl + name: anyio + version: 4.12.0 + sha256: dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb + requires_dist: + - exceptiongroup>=1.0.2 ; python_full_version < '3.11' + - idna>=2.8 + - typing-extensions>=4.5 ; python_full_version < '3.13' + - trio>=0.32.0 ; python_full_version >= '3.10' and extra == 'trio' + - trio>=0.31.0 ; python_full_version < '3.10' and extra == 'trio' + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl + name: rich + version: 15.0.0 + sha256: 33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb + requires_dist: + - ipywidgets>=7.5.1,<9 ; extra == 'jupyter' + - markdown-it-py>=2.2.0 + - pygments>=2.13.0,<3.0.0 + requires_python: '>=3.9.0' +- pypi: https://files.pythonhosted.org/packages/8a/a1/8d812e53a5da1687abb10445275d41a8b13adb781bbf7196ddbcf8d88505/lazy_loader-0.5-py3-none-any.whl + name: lazy-loader + version: '0.5' + sha256: ab0ea149e9c554d4ffeeb21105ac60bed7f3b4fd69b1d2360a4add51b170b005 + requires_dist: + - packaging + - pytest>=8.0 ; extra == 'test' + - pytest-cov>=5.0 ; extra == 'test' + - coverage[toml]>=7.2 ; extra == 'test' + - pre-commit==4.3.0 ; extra == 'lint' + - changelist==0.5 ; extra == 'dev' + - spin==0.15 ; extra == 'dev' + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/94/35/386550fd60316d1e37eccdda609b074113298f23cef5bddb2049823fe666/dacite-1.9.2-py3-none-any.whl + name: dacite + version: 1.9.2 + sha256: 053f7c3f5128ca2e9aceb66892b1a3c8936d02c686e707bee96e19deef4bc4a0 + requires_dist: + - dataclasses ; python_full_version < '3.7' + - pytest>=5 ; extra == 'dev' + - pytest-benchmark ; extra == 'dev' + - pytest-cov ; extra == 'dev' + - coveralls ; extra == 'dev' + - black ; extra == 'dev' + - mypy ; extra == 'dev' + - pylint ; extra == 'dev' + - pre-commit ; extra == 'dev' + requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl + name: charset-normalizer + version: 3.4.4 + sha256: e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794 + requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl + name: markupsafe + version: 3.0.3 + sha256: 116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219 + requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl + name: pycparser + version: '2.23' + sha256: e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934 + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/a3/88/1a3ea5672f4b0a84802ee9891b86743438e7c04eb0b8f8c4e16a42375327/regex-2025.11.3-cp313-cp313-macosx_11_0_arm64.whl + name: regex + version: 2025.11.3 + sha256: 04d2765516395cf7dda331a244a3282c0f5ae96075f728629287dfa6f76ba70a + requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/ad/31/0c2e734165abb46215797bd830c4bdcb780b699854b15f2b6240515edcc6/pyobjc_framework_cocoa-12.1-cp313-cp313-macosx_10_13_universal2.whl name: pyobjc-framework-cocoa version: '12.1' @@ -716,270 +1307,113 @@ packages: requires_dist: - pyobjc-core>=12.1 requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/20/a2/a3974e3e807c68e23a9d7db66fc38ac54f7ecd2b7a9237042006699a76e1/pyobjc_framework_coretext-12.1-cp313-cp313-macosx_10_13_universal2.whl - name: pyobjc-framework-coretext - version: '12.1' - sha256: 7cbb2c28580e6704ce10b9a991ccd9563a22b3a75f67c36cf612544bd8b21b5f - requires_dist: - - pyobjc-core>=12.1 - - pyobjc-framework-cocoa>=12.1 - - pyobjc-framework-quartz>=12.1 - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/ba/2d/e8f495328101898c16c32ac10e7b14b08ff2c443a756a76fd1271915f097/pyobjc_framework_quartz-12.1-cp313-cp313-macosx_10_13_universal2.whl - name: pyobjc-framework-quartz - version: '12.1' - sha256: 629b7971b1b43a11617f1460cd218bd308dfea247cd4ee3842eb40ca6f588860 - requires_dist: - - pyobjc-core>=12.1 - - pyobjc-framework-cocoa>=12.1 - requires_python: '>=3.10' -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.13.11-hfc2f54d_100_cp313.conda - build_number: 100 - sha256: c476f4e9b6d97c46b496b442878924868a54e5727251549ebfc82027aa52af68 - md5: 18a8c69608151098a8fb75eea64cc266 - depends: - - __osx >=11.0 - - bzip2 >=1.0.8,<2.0a0 - - libexpat >=2.7.3,<3.0a0 - - libffi >=3.5.2,<3.6.0a0 - - liblzma >=5.8.1,<6.0a0 - - libmpdec >=4.0.0,<5.0a0 - - libsqlite >=3.51.1,<4.0a0 - - libzlib >=1.3.1,<2.0a0 - - ncurses >=6.5,<7.0a0 - - openssl >=3.5.4,<4.0a0 - - python_abi 3.13.* *_cp313 - - readline >=8.2,<9.0a0 - - tk >=8.6.13,<8.7.0a0 - - tzdata - license: Python-2.0 - purls: [] - size: 12920650 - timestamp: 1765020887340 - python_site_packages_path: lib/python3.13/site-packages -- conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda - build_number: 8 - sha256: 210bffe7b121e651419cb196a2a63687b087497595c9be9d20ebe97dd06060a7 - md5: 94305520c52a4aa3f6c2b1ff6008d9f8 - constrains: - - python 3.13.* *_cp313 - license: BSD-3-Clause - license_family: BSD - purls: [] - size: 7002 - timestamp: 1752805902938 - pypi: https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl name: pyyaml version: 6.0.3 sha256: 2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 requires_python: '>=3.8' -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.2-h1d1bf99_2.conda - sha256: 7db04684d3904f6151eff8673270922d31da1eea7fa73254d01c437f49702e34 - md5: 63ef3f6e6d6d5c589e64f11263dc5676 - depends: - - ncurses >=6.5,<7.0a0 - license: GPL-3.0-only - license_family: GPL - purls: [] - size: 252359 - timestamp: 1740379663071 -- pypi: https://files.pythonhosted.org/packages/a3/88/1a3ea5672f4b0a84802ee9891b86743438e7c04eb0b8f8c4e16a42375327/regex-2025.11.3-cp313-cp313-macosx_11_0_arm64.whl - name: regex - version: 2025.11.3 - sha256: 04d2765516395cf7dda331a244a3282c0f5ae96075f728629287dfa6f76ba70a - requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl - name: requests - version: 2.32.5 - sha256: 2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6 - requires_dist: - - charset-normalizer>=2,<4 - - idna>=2.5,<4 - - urllib3>=1.21.1,<3 - - certifi>=2017.4.17 - - pysocks>=1.5.6,!=1.5.7 ; extra == 'socks' - - chardet>=3.0.2,<6 ; extra == 'use-chardet-on-py3' - requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/44/13/7e63cfba8a7452eb756306aa2fd9b37a29a323b672b964b4fdeded9a3f21/scipy-1.16.3-cp313-cp313-macosx_12_0_arm64.whl - name: scipy - version: 1.16.3 - sha256: 16b8bc35a4cc24db80a0ec836a9286d0e31b2503cb2fd7ff7fb0e0374a97081d +- pypi: https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl + name: mdurl + version: 0.1.2 + sha256: 84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 + requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl + name: markdown-it-py + version: 4.2.0 + sha256: 9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a requires_dist: - - numpy>=1.25.2,<2.6 - - pytest>=8.0.0 ; extra == 'test' - - pytest-cov ; extra == 'test' - - pytest-timeout ; extra == 'test' - - pytest-xdist ; extra == 'test' - - asv ; extra == 'test' - - mpmath ; extra == 'test' - - gmpy2 ; extra == 'test' - - threadpoolctl ; extra == 'test' - - scikit-umfpack ; extra == 'test' - - pooch ; extra == 'test' - - hypothesis>=6.30 ; extra == 'test' - - array-api-strict>=2.3.1 ; extra == 'test' - - cython ; extra == 'test' - - meson ; extra == 'test' - - ninja ; sys_platform != 'emscripten' and extra == 'test' - - sphinx>=5.0.0,<8.2.0 ; extra == 'doc' - - intersphinx-registry ; extra == 'doc' - - pydata-sphinx-theme>=0.15.2 ; extra == 'doc' - - sphinx-copybutton ; extra == 'doc' - - sphinx-design>=0.4.0 ; extra == 'doc' - - matplotlib>=3.5 ; extra == 'doc' - - numpydoc ; extra == 'doc' - - jupytext ; extra == 'doc' - - myst-nb>=1.2.0 ; extra == 'doc' - - pooch ; extra == 'doc' - - jupyterlite-sphinx>=0.19.1 ; extra == 'doc' - - jupyterlite-pyodide-kernel ; extra == 'doc' - - linkify-it-py ; extra == 'doc' - - mypy==1.10.0 ; extra == 'dev' - - typing-extensions ; extra == 'dev' - - types-psutil ; extra == 'dev' - - pycodestyle ; extra == 'dev' - - ruff>=0.0.292 ; extra == 'dev' - - cython-lint>=0.12.2 ; extra == 'dev' - - rich-click ; extra == 'dev' - - doit>=0.36.0 ; extra == 'dev' - - pydevtool ; extra == 'dev' - requires_python: '>=3.11' -- pypi: https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl - name: setuptools - version: 80.9.0 - sha256: 062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922 + - mdurl~=0.1 + - psutil ; extra == 'benchmarking' + - pytest ; extra == 'benchmarking' + - pytest-benchmark ; extra == 'benchmarking' + - commonmark~=0.9 ; extra == 'compare' + - markdown~=3.4 ; extra == 'compare' + - mistletoe~=1.0 ; extra == 'compare' + - mistune~=3.0 ; extra == 'compare' + - panflute~=2.3 ; extra == 'compare' + - markdown-it-pyrs ; extra == 'compare' + - linkify-it-py>=1,<3 ; extra == 'linkify' + - mdit-py-plugins>=0.5.0 ; extra == 'plugins' + - gprof2dot ; extra == 'profiling' + - mdit-py-plugins>=0.5.0 ; extra == 'rtd' + - myst-parser ; extra == 'rtd' + - pyyaml ; extra == 'rtd' + - sphinx ; extra == 'rtd' + - sphinx-copybutton ; extra == 'rtd' + - sphinx-design ; extra == 'rtd' + - sphinx-book-theme~=1.0 ; extra == 'rtd' + - jupyter-sphinx ; extra == 'rtd' + - ipykernel ; extra == 'rtd' + - coverage ; extra == 'testing' + - pytest ; extra == 'testing' + - pytest-cov ; extra == 'testing' + - pytest-regressions ; extra == 'testing' + - pytest-timeout ; extra == 'testing' + - requests ; extra == 'testing' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/b5/ba/c63c5786dfee4c3417094c4b00966e61e4a63efecee22cb7b4c0387dda83/librosa-0.11.0-py3-none-any.whl + name: librosa + version: 0.11.0 + sha256: 0b6415c4fd68bff4c29288abe67c6d80b587e0e1e2cfb0aad23e4559504a7fa1 requires_dist: - - pytest>=6,!=8.1.* ; extra == 'test' - - virtualenv>=13.0.0 ; extra == 'test' - - wheel>=0.44.0 ; extra == 'test' - - pip>=19.1 ; extra == 'test' - - packaging>=24.2 ; extra == 'test' - - jaraco-envs>=2.2 ; extra == 'test' - - pytest-xdist>=3 ; extra == 'test' - - jaraco-path>=3.7.2 ; extra == 'test' - - build[virtualenv]>=1.0.3 ; extra == 'test' - - filelock>=3.4.0 ; extra == 'test' - - ini2toml[lite]>=0.14 ; extra == 'test' - - tomli-w>=1.0.0 ; extra == 'test' - - pytest-timeout ; extra == 'test' - - pytest-perf ; sys_platform != 'cygwin' and extra == 'test' - - jaraco-develop>=7.21 ; python_full_version >= '3.9' and sys_platform != 'cygwin' and extra == 'test' - - pytest-home>=0.5 ; extra == 'test' - - pytest-subprocess ; extra == 'test' - - pyproject-hooks!=1.1 ; extra == 'test' - - jaraco-test>=5.5 ; extra == 'test' - - sphinx>=3.5 ; extra == 'doc' - - jaraco-packaging>=9.3 ; extra == 'doc' - - rst-linker>=1.9 ; extra == 'doc' - - furo ; extra == 'doc' - - sphinx-lint ; extra == 'doc' - - jaraco-tidelift>=1.4 ; extra == 'doc' - - pygments-github-lexers==0.0.5 ; extra == 'doc' - - sphinx-favicon ; extra == 'doc' - - sphinx-inline-tabs ; extra == 'doc' - - sphinx-reredirects ; extra == 'doc' - - sphinxcontrib-towncrier ; extra == 'doc' - - sphinx-notfound-page>=1,<2 ; extra == 'doc' - - pyproject-hooks!=1.1 ; extra == 'doc' - - towncrier<24.7 ; extra == 'doc' - - packaging>=24.2 ; extra == 'core' - - more-itertools>=8.8 ; extra == 'core' - - jaraco-text>=3.7 ; extra == 'core' - - importlib-metadata>=6 ; python_full_version < '3.10' and extra == 'core' - - tomli>=2.0.1 ; python_full_version < '3.11' and extra == 'core' - - wheel>=0.43.0 ; extra == 'core' - - platformdirs>=4.2.2 ; extra == 'core' - - jaraco-functools>=4 ; extra == 'core' - - more-itertools ; extra == 'core' - - pytest-checkdocs>=2.4 ; extra == 'check' - - pytest-ruff>=0.2.1 ; sys_platform != 'cygwin' and extra == 'check' - - ruff>=0.8.0 ; sys_platform != 'cygwin' and extra == 'check' - - pytest-cov ; extra == 'cover' - - pytest-enabler>=2.2 ; extra == 'enabler' - - pytest-mypy ; extra == 'type' - - mypy==1.14.* ; extra == 'type' - - importlib-metadata>=7.0.2 ; python_full_version < '3.10' and extra == 'type' - - jaraco-develop>=7.21 ; sys_platform != 'cygwin' and extra == 'type' - requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl - name: shellingham - version: 1.5.4 - sha256: 7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686 - requires_python: '>=3.7' + - audioread>=2.1.9 + - numba>=0.51.0 + - numpy>=1.22.3 + - scipy>=1.6.0 + - scikit-learn>=1.1.0 + - joblib>=1.0 + - decorator>=4.3.0 + - soundfile>=0.12.1 + - pooch>=1.1 + - soxr>=0.3.2 + - typing-extensions>=4.1.1 + - lazy-loader>=0.1 + - msgpack>=1.0 + - standard-aifc ; python_full_version >= '3.13' + - standard-sunau ; python_full_version >= '3.13' + - numpydoc ; extra == 'docs' + - sphinx!=1.3.1 ; extra == 'docs' + - sphinx-rtd-theme>=1.2.0 ; extra == 'docs' + - numba>=0.51 ; extra == 'docs' + - matplotlib>=3.5.0 ; extra == 'docs' + - sphinx-multiversion>=0.2.3 ; extra == 'docs' + - sphinx-gallery>=0.7 ; extra == 'docs' + - mir-eval>=0.5 ; extra == 'docs' + - ipython>=7.0 ; extra == 'docs' + - sphinxcontrib-svg2pdfconverter ; extra == 'docs' + - sphinxcontrib-googleanalytics>=0.4 ; extra == 'docs' + - presets ; extra == 'docs' + - sphinx-copybutton>=0.5.2 ; extra == 'docs' + - matplotlib>=3.5.0 ; extra == 'tests' + - packaging>=20.0 ; extra == 'tests' + - pytest-mpl ; extra == 'tests' + - pytest-cov ; extra == 'tests' + - pytest ; extra == 'tests' + - samplerate ; extra == 'tests' + - resampy>=0.2.2 ; extra == 'tests' + - types-decorator ; extra == 'tests' + - matplotlib>=3.5.0 ; extra == 'display' + requires_python: '>=3.8' - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl name: six version: 1.17.0 sha256: 4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 requires_python: '>=2.7,!=3.0.*,!=3.1.*,!=3.2.*' -- pypi: https://files.pythonhosted.org/packages/2f/39/714118f8413e0e353436914f2b976665161f1be2b6483ac15a8f61484c14/sounddevice-0.5.3-py3-none-macosx_10_6_x86_64.macosx_10_6_universal2.whl - name: sounddevice - version: 0.5.3 - sha256: 278dc4451fff70934a176df048b77d80d7ce1623a6ec9db8b34b806f3112f9c2 - requires_dist: - - cffi>=1.0 - - numpy ; extra == 'numpy' - requires_python: '>=3.7' -- pypi: https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl - name: sympy - version: 1.14.0 - sha256: e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5 - requires_dist: - - mpmath>=1.1.0,<1.4 - - pytest>=7.1.0 ; extra == 'dev' - - hypothesis>=6.70.0 ; extra == 'dev' - requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/1f/05/dcf94486d5c5c8d34496abe271ac76c5b785507c8eae71b3708f1ad9b45a/tiktoken-0.12.0-cp313-cp313-macosx_11_0_arm64.whl - name: tiktoken - version: 0.12.0 - sha256: a01b12f69052fbe4b080a2cfb867c4de12c704b56178edf1d1d7b273561db160 - requires_dist: - - regex>=2022.1.18 - - requests>=2.26.0 - - blobfile>=2 ; extra == 'blobfile' - requires_python: '>=3.9' -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h892fb3f_3.conda - sha256: ad0c67cb03c163a109820dc9ecf77faf6ec7150e942d1e8bb13e5d39dc058ab7 - md5: a73d54a5abba6543cb2f0af1bfbd6851 - depends: - - __osx >=11.0 - - libzlib >=1.3.1,<2.0a0 - license: TCL - license_family: BSD - purls: [] - size: 3125484 - timestamp: 1763055028377 -- pypi: https://files.pythonhosted.org/packages/40/60/71c698b466dd01e65d0e9514b5405faae200c52a76901baf6906856f17e4/torch-2.9.1-cp313-none-macosx_11_0_arm64.whl - name: torch - version: 2.9.1 - sha256: 2c14b3da5df416cf9cb5efab83aa3056f5b8cd8620b8fde81b4987ecab730587 +- pypi: https://files.pythonhosted.org/packages/ba/2d/e8f495328101898c16c32ac10e7b14b08ff2c443a756a76fd1271915f097/pyobjc_framework_quartz-12.1-cp313-cp313-macosx_10_13_universal2.whl + name: pyobjc-framework-quartz + version: '12.1' + sha256: 629b7971b1b43a11617f1460cd218bd308dfea247cd4ee3842eb40ca6f588860 requires_dist: - - filelock - - typing-extensions>=4.10.0 - - setuptools ; python_full_version >= '3.12' - - sympy>=1.13.3 - - networkx>=2.5.1 - - jinja2 - - fsspec>=0.8.5 - - nvidia-cuda-nvrtc-cu12==12.8.93 ; platform_machine == 'x86_64' and sys_platform == 'linux' - - nvidia-cuda-runtime-cu12==12.8.90 ; platform_machine == 'x86_64' and sys_platform == 'linux' - - nvidia-cuda-cupti-cu12==12.8.90 ; platform_machine == 'x86_64' and sys_platform == 'linux' - - nvidia-cudnn-cu12==9.10.2.21 ; platform_machine == 'x86_64' and sys_platform == 'linux' - - nvidia-cublas-cu12==12.8.4.1 ; platform_machine == 'x86_64' and sys_platform == 'linux' - - nvidia-cufft-cu12==11.3.3.83 ; platform_machine == 'x86_64' and sys_platform == 'linux' - - nvidia-curand-cu12==10.3.9.90 ; platform_machine == 'x86_64' and sys_platform == 'linux' - - nvidia-cusolver-cu12==11.7.3.90 ; platform_machine == 'x86_64' and sys_platform == 'linux' - - nvidia-cusparse-cu12==12.5.8.93 ; platform_machine == 'x86_64' and sys_platform == 'linux' - - nvidia-cusparselt-cu12==0.7.1 ; platform_machine == 'x86_64' and sys_platform == 'linux' - - nvidia-nccl-cu12==2.27.5 ; platform_machine == 'x86_64' and sys_platform == 'linux' - - nvidia-nvshmem-cu12==3.3.20 ; platform_machine == 'x86_64' and sys_platform == 'linux' - - nvidia-nvtx-cu12==12.8.90 ; platform_machine == 'x86_64' and sys_platform == 'linux' - - nvidia-nvjitlink-cu12==12.8.93 ; platform_machine == 'x86_64' and sys_platform == 'linux' - - nvidia-cufile-cu12==1.13.1.3 ; platform_machine == 'x86_64' and sys_platform == 'linux' - - triton==3.5.1 ; platform_machine == 'x86_64' and sys_platform == 'linux' - - optree>=0.13.0 ; extra == 'optree' - - opt-einsum>=3.3 ; extra == 'opt-einsum' - - pyyaml ; extra == 'pyyaml' + - pyobjc-core>=12.1 + - pyobjc-framework-cocoa>=12.1 requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/c3/52/5fbb203394cc852334d1575cc020f6bcec768d2265355984dfd361968f36/standard_aifc-3.13.0-py3-none-any.whl + name: standard-aifc + version: 3.13.0 + sha256: f7ae09cc57de1224a0dd8e3eb8f73830be7c3d0bc485de4c1f82b4a7f645ac66 + requires_dist: + - standard-chunk ; python_full_version >= '3.13' + - audioop-lts ; python_full_version >= '3.13' - pypi: https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl name: tqdm version: 4.67.1 @@ -996,49 +1430,137 @@ packages: - requests ; extra == 'telegram' - ipywidgets>=6 ; extra == 'notebook' requires_python: '>=3.7' -- pypi: https://files.pythonhosted.org/packages/5e/dd/5cbf31f402f1cc0ab087c94d4669cfa55bd1e818688b910631e131d74e75/typer_slim-0.20.0-py3-none-any.whl - name: typer-slim - version: 0.20.0 - sha256: f42a9b7571a12b97dddf364745d29f12221865acef7a2680065f9bb29c7dc89d +- pypi: https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl + name: shellingham + version: 1.5.4 + sha256: 7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686 + requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/e5/53/913099c91d384e115ea078325efd9a0bc1ea3eb3458c694b4596cbd267f2/mlx_lm-0.29.1-py3-none-any.whl + name: mlx-lm + version: 0.29.1 + sha256: 440941b3054c2a2216e97615de584cc90fa1ea874782e20699b9895721fad8dc requires_dist: - - click>=8.0.0 - - typing-extensions>=3.7.4.3 - - shellingham>=1.3.0 ; extra == 'standard' - - rich>=10.11.0 ; extra == 'standard' + - mlx>=0.29.2 ; sys_platform == 'darwin' + - numpy + - transformers>=4.39.3 + - sentencepiece + - protobuf + - pyyaml + - jinja2 + - datasets ; extra == 'test' + - lm-eval ; extra == 'test' + - datasets ; extra == 'train' + - tqdm ; extra == 'train' + - lm-eval ; extra == 'evaluate' + - tqdm ; extra == 'evaluate' + - mlx[cuda]>=0.29.2 ; extra == 'cuda' + - mlx[cpu]>=0.29.2 ; extra == 'cpu' requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl - name: typing-extensions - version: 4.15.0 - sha256: f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548 - requires_python: '>=3.9' -- conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h78e105d_0.conda - sha256: 5aaa366385d716557e365f0a4e9c3fca43ba196872abbbe3d56bb610d131e192 - md5: 4222072737ccff51314b5ece9c7d6f5a - license: LicenseRef-Public-Domain - purls: [] - size: 122968 - timestamp: 1742727099393 -- pypi: https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl - name: urllib3 - version: 2.6.2 - sha256: ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd +- pypi: https://files.pythonhosted.org/packages/e6/ff/3eba7eb0aed4b6fca37125387cd417e8c458e750621fce56d2c541f67fa8/llvmlite-0.46.0-cp313-cp313-macosx_12_0_arm64.whl + name: llvmlite + version: 0.46.0 + sha256: 30b60892d034bc560e0ec6654737aaa74e5ca327bd8114d82136aa071d611172 + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/ec/73/6fd0bb9ce84138c3857f12e9de63bc901852975a092d545f18087a204aa2/platformdirs-4.10.1-py3-none-any.whl + name: platformdirs + version: 4.10.1 + sha256: 0e4eff26be2d75293977f7cddc153fd9b8eaa7fb0c7b64ffe4076cb443117443 + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/f4/54/09a3501c74f1ef54a36002534661cb89a0b2f098fcad87fe64f515a0dba7/parakeet_mlx-0.5.2-py3-none-any.whl + name: parakeet-mlx + version: 0.5.2 + sha256: 50afb6ddb62237a6486e214482c25ef12759832fda3cd514e159938fa5970d9c requires_dist: - - brotli>=1.2.0 ; platform_python_implementation == 'CPython' and extra == 'brotli' - - brotlicffi>=1.2.0.0 ; platform_python_implementation != 'CPython' and extra == 'brotli' - - h2>=4,<5 ; extra == 'h2' - - pysocks>=1.5.6,!=1.5.7,<2.0 ; extra == 'socks' - - backports-zstd>=1.0.0 ; python_full_version < '3.14' and extra == 'zstd' + - dacite>=1.9.2 + - huggingface-hub>=0.30.2 + - librosa>=0.11.0 + - mlx>=0.22.1 + - numpy>=2.2.5 + - typer>=0.15.3 + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl + name: pygments + version: 2.20.0 + sha256: 81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 + requires_dist: + - colorama>=0.4.6 ; extra == 'windows-terminal' requires_python: '>=3.9' -- pypi: ./ - name: voice2text - version: 0.1.0 - sha256: ada7327128a261dcd02f3415d522a97c9342312fe0cb93927b4a602bfed6c180 +- pypi: https://files.pythonhosted.org/packages/f4/d2/29e5e536adc07bc3d33dd09f3f7cf844bf7b4981820dc2a91dd810f3c782/pyobjc_core-12.1-cp313-cp313-macosx_10_13_universal2.whl + name: pyobjc-core + version: '12.1' + sha256: 01c0cf500596f03e21c23aef9b5f326b9fb1f8f118cf0d8b66749b6cf4cbb37a + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/f5/b1/fa7c600e7dceae12e9606c7578cbc9ff1e1ed55844883ee5c92205e86226/safetensors-0.8.0-cp310-abi3-macosx_11_0_arm64.whl + name: safetensors + version: 0.8.0 + sha256: c80201d22cbf405b80647a60ada77bba06c8fba2da2743ba1e89cdcc39a81f25 requires_dist: - - mlx-whisper - - sounddevice - - numpy - - scipy - - pynput - - loguru - requires_python: '>=3.11' - editable: true + - safetensors[torch] ; extra == 'all' + - safetensors[numpy] ; extra == 'all' + - safetensors[jax] ; extra == 'all' + - safetensors[paddlepaddle] ; extra == 'all' + - safetensors[convert] ; extra == 'all' + - safetensors[quality] ; extra == 'all' + - safetensors[testing] ; extra == 'all' + - safetensors[torch] ; extra == 'convert' + - huggingface-hub>=1.4 ; extra == 'convert' + - safetensors[all] ; extra == 'dev' + - safetensors[pinned-tf] ; extra == 'dev' + - safetensors[numpy] ; extra == 'jax' + - flax>=0.6.3 ; extra == 'jax' + - jax>=0.3.25 ; extra == 'jax' + - jaxlib>=0.3.25 ; extra == 'jax' + - mlx>=0.0.9 ; extra == 'mlx' + - numpy>=1.24.6 ; extra == 'numpy' + - safetensors[numpy] ; extra == 'paddlepaddle' + - paddlepaddle>=2.4.1 ; extra == 'paddlepaddle' + - safetensors[numpy] ; extra == 'pinned-tf' + - tensorflow==2.18.0 ; extra == 'pinned-tf' + - ruff ; extra == 'quality' + - safetensors[numpy] ; extra == 'tensorflow' + - tensorflow>=2.11.0 ; extra == 'tensorflow' + - safetensors[numpy] ; extra == 'testing' + - h5py>=3.7.0 ; extra == 'testing' + - setuptools-rust>=1.12.0 ; extra == 'testing' + - pytest>=9.0 ; extra == 'testing' + - pytest-benchmark>=5.2 ; extra == 'testing' + - hypothesis>=6.70.2 ; extra == 'testing' + - fsspec>=2024.6.0 ; extra == 'testing' + - s3fs>=2024.6.0 ; extra == 'testing' + - safetensors[numpy] ; extra == 'tf-nightly' + - tf-nightly ; extra == 'tf-nightly' + - safetensors[numpy] ; extra == 'torch' + - torch>=2.4 ; extra == 'torch' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl + name: click + version: 8.4.2 + sha256: e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76 + requires_dist: + - colorama ; sys_platform == 'win32' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/fc/21/79e42ee836f1010f5fe9e97d2817a006736bd287c15a3674c399190a2e77/pyobjc_framework_applicationservices-12.1-cp313-cp313-macosx_10_13_universal2.whl + name: pyobjc-framework-applicationservices + version: '12.1' + sha256: bd1f4dbb38234a24ae6819f5e22485cf7dd3dd4074ff3bf9a9fdb4c01a3b4a38 + requires_dist: + - pyobjc-core>=12.1 + - pyobjc-framework-cocoa>=12.1 + - pyobjc-framework-quartz>=12.1 + - pyobjc-framework-coretext>=12.1 + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/fe/a2/078152b45aa8a23949a1b09601d0044f8bb4ab85e909e4475a440c21aaea/mlx-0.29.3-cp313-cp313-macosx_13_0_arm64.whl + name: mlx + version: 0.29.3 + sha256: d59eccf6a1e1e131becc5a3910504507862da3a4e9b7bd9e73a625515d767844 + requires_dist: + - mlx-metal==0.29.3 ; sys_platform == 'darwin' + - nanobind==2.4.0 ; extra == 'dev' + - numpy ; extra == 'dev' + - pre-commit ; extra == 'dev' + - setuptools>=80 ; extra == 'dev' + - torch ; extra == 'dev' + - typing-extensions ; extra == 'dev' + - mlx-cuda==0.29.3 ; sys_platform == 'linux' and extra == 'cuda' + - mlx-cpu==0.29.3 ; sys_platform == 'linux' and extra == 'cpu' + requires_python: '>=3.9' diff --git a/pyproject.toml b/pyproject.toml index 8bb65e2..b775894 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ license = "GPL-2.0-only" requires-python = ">=3.11" dependencies = [ "sounddevice", - "numpy", + "numpy<2.4", "scipy", "pynput", "loguru", @@ -25,7 +25,7 @@ whisper = ["mlx-whisper"] v2t = "v2t.cli:main" [dependency-groups] -dev = ["hatch", "ruff"] +dev = ["hatch", "mlx-whisper", "ruff"] [build-system] requires = ["hatchling"] @@ -33,6 +33,23 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["v2t"] +exclude = ["v2t/bench.py"] + +[tool.hatch.build.targets.wheel.force-include] +"swiftbar/v2t.5s.sh" = "swiftbar/v2t.5s.sh" + +[tool.hatch.build.targets.sdist] +include = [ + "/CHANGELOG.md", + "/LICENSE", + "/README.md", + "/benchmarks", + "/justfile", + "/pyproject.toml", + "/swiftbar", + "/tests", + "/v2t", +] [tool.pixi.workspace] channels = ["conda-forge"] diff --git a/swiftbar/v2t.5s.sh b/swiftbar/v2t.5s.sh index 2daf1bb..d6640ea 100755 --- a/swiftbar/v2t.5s.sh +++ b/swiftbar/v2t.5s.sh @@ -19,21 +19,22 @@ # below, then RESTART that app — macOS only applies the grant on relaunch. export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" -V2T_HOME="${V2T_HOME:-$HOME/.v2t}" -mkdir -p "$V2T_HOME/run" "$V2T_HOME/history" # so the Open… items always have a target +umask 077 +if [ -z "${V2T_HOME:-}" ]; then + V2T_HOME="${XDG_CONFIG_HOME:+$XDG_CONFIG_HOME/v2t}" + V2T_HOME="${V2T_HOME:-$HOME/.v2t}" +fi +export V2T_HOME +mkdir -p "$V2T_HOME/run" "$V2T_HOME/history" +chmod 700 "$V2T_HOME" "$V2T_HOME/run" "$V2T_HOME/history" SEC="x-apple.systempreferences:com.apple.preference.security" +SERVICE_PLIST="$HOME/Library/LaunchAgents/com.lucharo.voice2text.plist" V2T_BIN="$(command -v v2t || true)" for c in "$HOME/.local/bin/v2t" /opt/homebrew/bin/v2t /usr/local/bin/v2t; do [ -z "$V2T_BIN" ] && [ -x "$c" ] && V2T_BIN="$c" done -# SwiftBar re-invokes this script with a param when a menu item is clicked. -case "$1" in - start) nohup "$V2T_BIN" >>"$V2T_HOME/run/v2t.log" 2>&1 & exit 0 ;; - stop) "$V2T_BIN" stop >/dev/null 2>&1; exit 0 ;; -esac - if [ -z "$V2T_BIN" ]; then echo "🎙️ ⚠️" echo "---" @@ -42,20 +43,47 @@ if [ -z "$V2T_BIN" ]; then exit 0 fi -# `v2t status` -> "\t\t\t" (state is 'off' when not running) -STATUS="$("$V2T_BIN" status 2>/dev/null)" +# SwiftBar re-invokes this script with a param when a menu item is clicked. +case "$1" in + start) + LOG="$V2T_HOME/run/v2t.log" + [ -f "$LOG" ] && [ "$(wc -c <"$LOG")" -gt 1048576 ] && mv -f "$LOG" "$LOG.1" + touch "$LOG"; chmod 600 "$LOG" + if [ -f "$SERVICE_PLIST" ]; then + "$V2T_BIN" service start >>"$LOG" 2>&1 + else + nohup "$V2T_BIN" >>"$LOG" 2>&1 & + fi + exit 0 + ;; + stop) + "$V2T_BIN" stop >/dev/null 2>&1 + exit 0 + ;; +esac + +# `v2t status` -> "\t\t\t\t" +if ! STATUS="$("$V2T_BIN" status 2>/dev/null)"; then + STATUS="$(printf 'config-error\t?\t?\t?\tv2t status failed — check config or log')" +fi STATE="$(printf '%s' "$STATUS" | cut -f1)" STT="$(printf '%s' "$STATUS" | cut -f2)" CLEANUP="$(printf '%s' "$STATUS" | cut -f3)" MODE="$(printf '%s' "$STATUS" | cut -f4)" +ERROR="$(printf '%s' "$STATUS" | cut -f5)" [ "$CLEANUP" = "off" ] && CLEAN_LBL="no cleanup" || CLEAN_LBL="clean: $CLEANUP" case "$STATE" in - starting) TITLE="🎙️🟠"; LINE="🟠 Starting… (loading models)" ; RUN=1 ;; + loading-stt) TITLE="🎙️🟠"; LINE="🟠 Loading transcription model…" ; RUN=1 ;; + loading-cleanup) TITLE="🎙️🟠"; LINE="🟠 Loading cleanup model…" ; RUN=1 ;; idle) TITLE="🎙️🟢"; LINE="🟢 Ready ($MODE)" ; RUN=1 ;; recording) TITLE="🎙️🔴"; LINE="🔴 Recording…" ; RUN=1 ;; transcribing) TITLE="🎙️🟡"; LINE="🟡 Transcribing…" ; RUN=1 ;; cleaning) TITLE="🎙️🟡"; LINE="🟡 Cleaning up…" ; RUN=1 ;; + stopping) TITLE="🎙️🟠"; LINE="🟠 Finishing current transcription…" ; RUN=1 ;; + error) TITLE="🎙️⚠️"; LINE="⚠️ $ERROR" ; RUN=1 ;; + launch-error) TITLE="🎙️⚠️"; LINE="⚠️ $ERROR" ; RUN=0 ;; + config-error) TITLE="🎙️⚠️"; LINE="⚠️ $ERROR" ; RUN=0 ;; *) TITLE="🎙️"; LINE="○ Off" ; RUN=0 ;; esac @@ -68,6 +96,7 @@ if [ "$RUN" = "1" ]; then else echo "Start v2t | bash=\"$0\" param0=start terminal=false refresh=true" fi +[ -f "$SERVICE_PLIST" ] && echo "Start at login: on | color=#8e8e93 size=12" || echo "Start at login: off | color=#8e8e93 size=12" echo "---" echo "Permissions (grant, then restart the launching app)" echo "--🎙️ Microphone | bash=/usr/bin/open param0=\"$SEC?Privacy_Microphone\" terminal=false" @@ -75,4 +104,5 @@ echo "--♿ Accessibility | bash=/usr/bin/open param0=\"$SEC?Privacy_Accessibili echo "--⌨️ Input Monitoring | bash=/usr/bin/open param0=\"$SEC?Privacy_ListenEvent\" terminal=false" echo "Open config (~/.v2t) | bash=/usr/bin/open param0=\"$V2T_HOME\" terminal=false" echo "Open transcription history | bash=/usr/bin/open param0=\"$V2T_HOME/history\" terminal=false" +[ -f "$V2T_HOME/run/v2t.log" ] && echo "Open log | bash=/usr/bin/open param0=\"$V2T_HOME/run/v2t.log\" terminal=false" echo "Refresh | refresh=true" diff --git a/tests/test_smoke.py b/tests/test_smoke.py new file mode 100644 index 0000000..daf6eca --- /dev/null +++ b/tests/test_smoke.py @@ -0,0 +1,278 @@ +"""Small behavior checks for v2t's user-facing mechanics.""" + +from __future__ import annotations + +import contextlib +import io +import os +import plistlib +import stat +import sys +import tempfile +import types +import unittest +from pathlib import Path +from unittest import mock + +import numpy as np + +from v2t import app, backends, bench, cli, config, service + + +class V2TSmokeTests(unittest.TestCase): + def setUp(self): + self.tempdir = tempfile.TemporaryDirectory() + self.addCleanup(self.tempdir.cleanup) + self.env = mock.patch.dict( + os.environ, {"V2T_HOME": self.tempdir.name}, clear=False + ) + self.env.start() + self.addCleanup(self.env.stop) + + def test_audio_device_failure_returns_to_error_state(self): + voice = app.VoiceToText(config.Config(cleanup_enabled=False)) + lock = config.acquire_instance_lock() + self.addCleanup(lock.close) + + with ( + mock.patch.object( + app.sd, "InputStream", side_effect=RuntimeError("device unavailable") + ), + mock.patch.object(app, "_refresh_swiftbar"), + mock.patch.object(app.subprocess, "run") as run, + ): + voice.start_recording() + + self.assertFalse(voice.recording) + self.assertIsNone(voice.stream) + self.assertEqual(config.read_status()["state"], "error") + self.assertNotIn( + ["nowplaying-cli", "pause"], [call.args[0] for call in run.call_args_list] + ) + + def test_permission_check_reports_each_missing_native_permission(self): + permissions = types.SimpleNamespace( + AXIsProcessTrusted=lambda: True, + CGPreflightListenEventAccess=lambda: False, + ) + with ( + mock.patch.dict(sys.modules, {"ApplicationServices": permissions}), + mock.patch.object(app.subprocess, "run") as run, + self.assertRaises(SystemExit), + ): + app.check_and_request_permissions() + + run.assert_any_call( + [ + "open", + "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent", + ], + check=False, + ) + self.assertIn("Input Monitoring", config.read_last_error()) + + def test_status_reports_the_running_overrides(self): + cfg = config.Config(cleanup_enabled=False, mode="casual") + voice = app.VoiceToText(cfg) + lock = config.acquire_instance_lock() + self.addCleanup(lock.close) + + with mock.patch.object(app, "_refresh_swiftbar"): + voice._set_state("idle") + output = io.StringIO() + with contextlib.redirect_stdout(output): + cli.cmd_status([]) + + self.assertEqual(output.getvalue(), "idle\tparakeet-v3\toff\tcasual\t\n") + + def test_recording_cannot_restart_before_processing_begins(self): + voice = app.VoiceToText(config.Config(cleanup_enabled=False)) + voice.recording = True + voice.record_start = app.time.perf_counter() + voice.frames = [np.ones((2, 1), dtype=np.float32)] + voice.stream = mock.Mock() + + with ( + mock.patch.object(app.threading, "Thread") as thread, + mock.patch.object(app.sd, "InputStream") as input_stream, + ): + voice.stop_recording() + voice.start_recording() + + self.assertTrue(voice.processing) + self.assertEqual(voice.frames, []) + thread.return_value.start.assert_called_once_with() + input_stream.assert_not_called() + + def test_transcription_pipeline_pastes_cleanup_and_deletes_audio(self): + voice = app.VoiceToText(config.Config(save_history=False)) + audio_path = None + + def transcribe(path): + nonlocal audio_path + audio_path = path + return "raw words" + + voice.stt = mock.Mock(transcribe=transcribe) + voice.cleaner = mock.Mock(model_id="cleaner") + voice.cleaner.cleanup.return_value = ("Clean words.", 0.1, 0.2) + voice.processing = True + lock = config.acquire_instance_lock() + self.addCleanup(lock.close) + + with ( + mock.patch.object(voice, "paste_to_cursor") as paste, + mock.patch.object(app, "_refresh_swiftbar"), + ): + voice.process_audio([np.ones((8, 1), dtype=np.float32)], 1.0) + + paste.assert_called_once_with("Clean words.") + self.assertIsNotNone(audio_path) + self.assertFalse(Path(audio_path).exists()) + self.assertEqual(config.read_status()["state"], "idle") + + def test_clipboard_is_restored_when_paste_fails(self): + voice = app.VoiceToText(config.Config(cleanup_enabled=False)) + original = mock.Mock() + original.types.return_value = ["public.png", "public.utf8-plain-text"] + original.dataForType_.side_effect = lambda kind: { + "public.png": b"png", + "public.utf8-plain-text": b"text", + }[kind] + pasteboard = mock.Mock() + pasteboard.pasteboardItems.return_value = [original] + pasteboard.setString_forType_.return_value = True + restored_item = mock.Mock() + item_class = mock.Mock() + item_class.alloc.return_value.init.return_value = restored_item + fake_appkit = types.SimpleNamespace( + NSPasteboard=mock.Mock( + generalPasteboard=mock.Mock(return_value=pasteboard) + ), + NSPasteboardItem=item_class, + NSPasteboardTypeString="public.utf8-plain-text", + ) + + with ( + mock.patch.dict(sys.modules, {"AppKit": fake_appkit}), + mock.patch.object( + app.subprocess, "run", side_effect=RuntimeError("paste failed") + ), + self.assertRaisesRegex(RuntimeError, "paste failed"), + ): + voice.paste_to_cursor("dictated") + + self.assertEqual( + [call.args for call in restored_item.setData_forType_.call_args_list], + [(b"png", "public.png"), (b"text", "public.utf8-plain-text")], + ) + pasteboard.writeObjects_.assert_called_once_with([restored_item]) + + def test_history_is_private_and_valid_jsonl(self): + config.append_history({"raw": "hello", "clean": "Hello."}) + + path = config.history_path() + mode = stat.S_IMODE(path.stat().st_mode) + + self.assertEqual(mode, 0o600) + self.assertEqual(path.read_text().count("\n"), 1) + self.assertEqual(stat.S_IMODE(path.parent.stat().st_mode), 0o700) + + def test_custom_config_keeps_existing_parent_permissions(self): + parent = Path(self.tempdir.name) / "shared" + parent.mkdir(mode=0o755) + path = parent / "v2t.toml" + + config.write_config("[cleanup]\nenabled = false\n", path) + + self.assertEqual(stat.S_IMODE(parent.stat().st_mode), 0o755) + self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o600) + + def test_second_instance_lock_is_rejected(self): + first = config.acquire_instance_lock() + self.addCleanup(first.close) + + with self.assertRaises(BlockingIOError): + config.acquire_instance_lock() + + def test_missing_optional_stt_is_rendered_as_not_available(self): + samples = [("short", Path("short.wav"), 1.0)] + with mock.patch.object(backends, "make_stt", side_effect=SystemExit("missing")): + results = bench.bench_stt(["whisper:model"], samples, repeat=1) + + table = bench.md_stt_table(results, samples) + + self.assertIsNone(results["whisper:model"]) + self.assertIn("| load | n/a |", table) + self.assertIn("| short (1.0s) | n/a |", table) + + def test_setup_recommends_the_real_parakeet_install(self): + output = io.StringIO() + with ( + mock.patch("builtins.input", side_effect=["", "n"]), + contextlib.redirect_stdout(output), + ): + cli.cmd_setup([]) + + self.assertIn("uv tool install voice2text", output.getvalue()) + self.assertNotIn("voice2text[parakeet]", output.getvalue()) + self.assertEqual(stat.S_IMODE(config.config_path().stat().st_mode), 0o600) + + def test_swiftbar_command_installs_the_bundled_plugin(self): + plugin_dir = Path(self.tempdir.name) / "plugins" + with mock.patch.object(cli.subprocess, "run"): + cli.cmd_swiftbar(["--dir", str(plugin_dir)]) + + installed = plugin_dir / "v2t.5s.sh" + source = Path(cli.__file__).resolve().parent.parent / "swiftbar" / "v2t.5s.sh" + self.assertEqual(installed.read_text(), source.read_text()) + self.assertEqual(stat.S_IMODE(installed.stat().st_mode), 0o755) + + def test_launch_agent_keeps_one_warm_v2t_process(self): + plist = Path(self.tempdir.name) / "com.lucharo.voice2text.plist" + with ( + mock.patch.object(service, "plist_path", return_value=plist), + mock.patch.object(service, "loaded", return_value=False), + mock.patch.object(service, "service_pid", return_value=None), + mock.patch.object(service, "start") as start, + mock.patch.object(service.sys, "platform", "darwin"), + ): + service.install() + + data = plistlib.loads(plist.read_bytes()) + self.assertEqual(data["ProgramArguments"], [sys.executable, "-m", "v2t"]) + self.assertTrue(data["RunAtLoad"]) + self.assertNotIn("KeepAlive", data) + self.assertEqual(data["EnvironmentVariables"]["V2T_HOME"], self.tempdir.name) + start.assert_called_once_with() + + def test_service_start_does_not_restart_a_healthy_process(self): + plist = Path(self.tempdir.name) / "com.lucharo.voice2text.plist" + plist.touch() + with ( + mock.patch.object(service, "plist_path", return_value=plist), + mock.patch.object(service, "service_pid", return_value=42), + mock.patch.object(config, "running_pid", return_value=42), + mock.patch.object(service, "_launchctl") as launchctl, + ): + service.start() + + launchctl.assert_not_called() + + def test_cleanup_refuses_to_return_a_capped_partial_result(self): + cleaner = object.__new__(backends.MLXCleanup) + cleaner.model = object() + cleaner.tokenizer = mock.Mock() + cleaner.tokenizer.apply_chat_template.return_value = "prompt" + cleaner.tokenizer.encode.return_value = list(range(10)) + response = type("Response", (), {"text": "x"}) + cleaner._stream = lambda *_args, **kwargs: ( + response() for _ in range(kwargs["max_tokens"]) + ) + + with self.assertRaisesRegex(RuntimeError, "token limit"): + cleaner.cleanup("hello") + + +if __name__ == "__main__": + unittest.main() diff --git a/uv.lock b/uv.lock index cbde426..6b933c1 100644 --- a/uv.lock +++ b/uv.lock @@ -620,7 +620,7 @@ name = "importlib-metadata" version = "8.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp", marker = "python_full_version < '3.12'" }, + { name = "zipp" }, ] sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } wheels = [ @@ -2059,8 +2059,8 @@ name = "standard-aifc" version = "3.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "audioop-lts", marker = "python_full_version >= '3.13'" }, - { name = "standard-chunk", marker = "python_full_version >= '3.13'" }, + { name = "audioop-lts" }, + { name = "standard-chunk" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c4/53/6050dc3dde1671eb3db592c13b55a8005e5040131f7509cef0215212cb84/standard_aifc-3.13.0.tar.gz", hash = "sha256:64e249c7cb4b3daf2fdba4e95721f811bde8bdfc43ad9f936589b7bb2fae2e43", size = 15240, upload-time = "2024-10-30T16:01:31.772Z" } wheels = [ @@ -2081,7 +2081,7 @@ name = "standard-sunau" version = "3.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "audioop-lts", marker = "python_full_version >= '3.13'" }, + { name = "audioop-lts" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/e3/ce8d38cb2d70e05ffeddc28bb09bad77cfef979eb0a299c9117f7ed4e6a9/standard_sunau-3.13.0.tar.gz", hash = "sha256:b319a1ac95a09a2378a8442f403c66f4fd4b36616d6df6ae82b8e536ee790908", size = 9368, upload-time = "2024-10-30T16:01:41.626Z" } wheels = [ @@ -2424,6 +2424,7 @@ whisper = [ [package.dev-dependencies] dev = [ { name = "hatch" }, + { name = "mlx-whisper" }, { name = "ruff" }, ] @@ -2432,7 +2433,7 @@ requires-dist = [ { name = "loguru" }, { name = "mlx-lm" }, { name = "mlx-whisper", marker = "extra == 'whisper'" }, - { name = "numpy" }, + { name = "numpy", specifier = "<2.4" }, { name = "parakeet-mlx" }, { name = "pynput" }, { name = "scipy" }, @@ -2443,6 +2444,7 @@ provides-extras = ["whisper"] [package.metadata.requires-dev] dev = [ { name = "hatch" }, + { name = "mlx-whisper" }, { name = "ruff" }, ] diff --git a/v2t/app.py b/v2t/app.py index 6af7d56..5c1708f 100644 --- a/v2t/app.py +++ b/v2t/app.py @@ -1,11 +1,10 @@ """The push-to-talk engine: record on hotkey, transcribe, clean up, paste. -macOS-only at runtime (osascript paste, pbcopy/pbpaste, System Events perms). +macOS-only at runtime (native pasteboard, System Events paste, global hotkey). """ from __future__ import annotations -import json import os import signal import subprocess @@ -13,6 +12,7 @@ import tempfile import threading import time +from pathlib import Path import numpy as np import sounddevice as sd @@ -29,7 +29,11 @@ def _refresh_swiftbar() -> None: """Best-effort: tell SwiftBar to repaint the menu-bar icon right now.""" try: - subprocess.run(["open", "-g", "swiftbar://refreshplugin?name=v2t"], check=False, capture_output=True) + subprocess.run( + ["open", "-g", "swiftbar://refreshplugin?name=v2t"], + check=False, + capture_output=True, + ) except OSError: pass @@ -38,9 +42,12 @@ def _resolve_hotkey(name: str): from pynput import keyboard keys = { - "cmd_r": keyboard.Key.cmd_r, "cmd_l": keyboard.Key.cmd_l, - "alt_r": keyboard.Key.alt_r, "alt_l": keyboard.Key.alt_l, - "ctrl_r": keyboard.Key.ctrl_r, "ctrl_l": keyboard.Key.ctrl_l, + "cmd_r": keyboard.Key.cmd_r, + "cmd_l": keyboard.Key.cmd_l, + "alt_r": keyboard.Key.alt_r, + "alt_l": keyboard.Key.alt_l, + "ctrl_r": keyboard.Key.ctrl_r, + "ctrl_l": keyboard.Key.ctrl_l, } if name not in keys: raise SystemExit(f"unknown hotkey {name!r}; choose: {', '.join(keys)}") @@ -48,24 +55,29 @@ def _resolve_hotkey(name: str): def check_and_request_permissions() -> None: - """Open the right System Settings panes if Accessibility/Input Monitoring are missing.""" + """Fail early with the exact macOS permission panes that still need a grant.""" + from ApplicationServices import AXIsProcessTrusted, CGPreflightListenEventAccess + logger.info("Checking permissions...") - test = subprocess.run( - ["osascript", "-e", 'tell application "System Events" to return "ok"'], - capture_output=True, text=True, - ) - if "not allowed" in test.stderr.lower() or test.returncode != 0: - logger.warning("Permissions needed! Grant them to your TERMINAL APP (or SwiftBar if you start v2t from the menu).") - subprocess.run(["open", "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"]) - if not sys.stdin.isatty(): - # Launched headless (e.g. SwiftBar): can't prompt. Tell the user and exit cleanly. - logger.error("Grant Accessibility + Input Monitoring in System Settings, then start v2t again.") - sys.exit(1) - input("Press Enter after granting Accessibility permission...") - subprocess.run(["open", "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent"]) - input("Press Enter after granting Input Monitoring permission...") - logger.success("Permissions granted. Restart your terminal if the hotkey doesn't fire, then run again.") - sys.exit(0) + checks = [ + ("Accessibility", bool(AXIsProcessTrusted()), "Privacy_Accessibility"), + ( + "Input Monitoring", + bool(CGPreflightListenEventAccess()), + "Privacy_ListenEvent", + ), + ] + missing = [(name, pane) for name, granted, pane in checks if not granted] + if missing: + names = " + ".join(name for name, _pane in missing) + message = f"Grant {names} to the launching app, restart that app, then start v2t again." + config.write_last_error(message) + _refresh_swiftbar() + logger.error(message) + security = "x-apple.systempreferences:com.apple.preference.security" + for _name, pane in missing: + subprocess.run(["open", f"{security}?{pane}"], check=False) + raise SystemExit(1) logger.success("Permissions OK") @@ -73,7 +85,7 @@ class VoiceToText: def __init__(self, cfg: Config): self.cfg = cfg self.stt_model = cfg.stt_model or backends.STT[cfg.backend].default_model - self.stt = None # loaded in run() + self.stt = None # loaded in run() self.cleaner = None # loaded in run() if cleanup is enabled self.recording = False self.processing = False @@ -82,79 +94,144 @@ def __init__(self, cfg: Config): self.record_start = 0.0 self.was_playing = False self._warned_mic = False + self.worker = None + self.instance_lock = None + self.stopping = False + cleanup_model = ( + cfg.cleanup_model or backends.CLEANUP[cfg.cleanup_engine].default_model + ) + self.status_details = { + "stt": backends.short_model(self.stt_model), + "cleanup": backends.short_model(cleanup_model) + if cfg.cleanup_enabled + else "off", + "mode": cfg.mode, + } # --- live status for the SwiftBar plugin ------------------------------- # state: starting | idle | recording | transcribing | cleaning. Written on # every transition (and the icon repainted) so actions get instant feedback. - def _set_state(self, state: str) -> None: - d = config.run_dir() - d.mkdir(parents=True, exist_ok=True) - (d / "status.json").write_text(json.dumps({"pid": os.getpid(), "state": state})) + def _set_state(self, state: str, error: str = "") -> None: + clean_error = " ".join(error.split()) + config.write_status( + { + "pid": os.getpid(), + "state": state, + **self.status_details, + "error": clean_error, + } + ) _refresh_swiftbar() def _clear_status(self) -> None: - (config.run_dir() / "status.json").unlink(missing_ok=True) + config.clear_status() _refresh_swiftbar() + def _close_stream(self) -> None: + if self.stream is not None: + stream, self.stream = self.stream, None + try: + stream.stop() + except Exception as error: + logger.warning(f"Could not stop audio input cleanly: {error}") + try: + stream.close() + except Exception as error: + logger.warning(f"Could not close audio input cleanly: {error}") + + def _restore_media(self) -> None: + if self.cfg.pause_music and self.was_playing: + subprocess.run(["nowplaying-cli", "play"], check=False) + self.was_playing = False + # --- recording ---------------------------------------------------------- def audio_callback(self, indata, frame_count, time_info, status): + if status: + logger.warning(f"Audio input: {status}") if self.recording: self.frames.append(indata.copy()) def start_recording(self): if self.recording or self.processing: return - self.recording = True self.frames = [] self.record_start = time.perf_counter() - self._set_state("recording") + try: + self.stream = sd.InputStream( + samplerate=self.cfg.sample_rate, + channels=1, + dtype="float32", + callback=self.audio_callback, + ) + self.recording = True + self.stream.start() + except Exception as error: + self.recording = False + self._close_stream() + logger.error(f"Could not open the microphone: {error}") + self._set_state("error", f"Microphone unavailable: {error}") + if not self._warned_mic: + self._warned_mic = True + subprocess.run(["open", MIC_PANE], check=False) + return + if self.cfg.pause_music: - r = subprocess.run(["nowplaying-cli", "get", "playbackRate"], capture_output=True, text=True) - self.was_playing = r.stdout.strip() == "1" + result = subprocess.run( + ["nowplaying-cli", "get", "playbackRate"], + capture_output=True, + text=True, + check=False, + ) + self.was_playing = result.stdout.strip() == "1" if self.was_playing: - subprocess.run(["nowplaying-cli", "pause"]) + subprocess.run(["nowplaying-cli", "pause"], check=False) + self._set_state("recording") logger.info("Recording...") - self.stream = sd.InputStream( - samplerate=self.cfg.sample_rate, channels=1, dtype="float32", callback=self.audio_callback, - ) - self.stream.start() def stop_recording(self): if not self.recording: return self.recording = False duration = time.perf_counter() - self.record_start - if self.stream: - self.stream.stop() - self.stream.close() - self.stream = None + self._close_stream() logger.info(f"Stopped ({duration:.1f}s)") if self.frames: - threading.Thread(target=self.process_audio, args=(duration,), daemon=True).start() + frames, self.frames = self.frames, [] + self.processing = True + self.worker = threading.Thread( + target=self.process_audio, args=(frames, duration) + ) + self.worker.start() else: + self._restore_media() self._set_state("idle") - def process_audio(self, audio_s: float): - self.processing = True + def process_audio(self, frames: list[np.ndarray], audio_s: float): + next_state, error_message, temp_path = "idle", "", None try: self._set_state("transcribing") - audio = np.concatenate(self.frames, axis=0) - if float(np.abs(audio).max()) < 1e-4: # dead silence == no mic access, not a quiet room - logger.error("No audio captured. Grant Microphone permission to the app that launched " - "v2t (your terminal, or SwiftBar), then RESTART that app.") + audio = np.concatenate(frames, axis=0) + if ( + float(np.abs(audio).max()) < 1e-4 + ): # dead silence == no mic access, not a quiet room + error_message = "No audio captured. Check Microphone permission, then restart the launching app." + logger.error(error_message) if not self._warned_mic: self._warned_mic = True - subprocess.run(["open", MIC_PANE]) + subprocess.run(["open", MIC_PANE], check=False) + next_state = "error" return with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: - wavfile.write(f.name, self.cfg.sample_rate, (audio * 32767).astype(np.int16)) + wavfile.write( + f.name, self.cfg.sample_rate, (audio * 32767).astype(np.int16) + ) temp_path = f.name logger.info("Transcribing...") t0 = time.perf_counter() raw_text = self.stt.transcribe(temp_path) stt_s = time.perf_counter() - t0 - logger.info(f"Raw: {raw_text} ({stt_s:.2f}s)") + logger.info(f"Transcribed {len(raw_text)} characters ({stt_s:.2f}s)") if not raw_text: logger.warning("No speech detected") return @@ -164,10 +241,14 @@ def process_audio(self, audio_s: float): self._set_state("cleaning") logger.info("Cleaning up...") try: - cleaned_text, _ttft, cleanup_s = self.cleaner.cleanup(raw_text, self.cfg.mode) + cleaned_text, _ttft, cleanup_s = self.cleaner.cleanup( + raw_text, self.cfg.mode + ) if not cleaned_text: raise RuntimeError("empty response") - logger.info(f"Clean: {cleaned_text} ({cleanup_s:.2f}s)") + logger.info( + f"Cleaned {len(cleaned_text)} characters ({cleanup_s:.2f}s)" + ) except Exception as e: logger.error(f"LLM cleanup failed: {e}") logger.warning("Falling back to raw transcription") @@ -177,26 +258,74 @@ def process_audio(self, audio_s: float): logger.success("Pasted!") if self.cfg.save_history: - config.append_history({ - "audio_s": round(audio_s, 2), "backend": self.cfg.backend, "model": self.stt_model, - "cleanup_engine": self.cfg.cleanup_engine if self.cleaner else None, - "cleanup_model": self.cleaner.model_id if self.cleaner else None, - "mode": self.cfg.mode, "stt_s": round(stt_s, 3), "cleanup_s": round(cleanup_s, 3), - "raw": raw_text, "clean": cleaned_text, - }) + try: + config.append_history( + { + "audio_s": round(audio_s, 2), + "backend": self.cfg.backend, + "model": self.stt_model, + "cleanup_engine": self.cfg.cleanup_engine + if self.cleaner + else None, + "cleanup_model": self.cleaner.model_id + if self.cleaner + else None, + "mode": self.cfg.mode, + "stt_s": round(stt_s, 3), + "cleanup_s": round(cleanup_s, 3), + "raw": raw_text, + "clean": cleaned_text, + } + ) + except OSError as error: + logger.warning(f"Could not save transcription history: {error}") + except Exception as error: + next_state, error_message = "error", f"{type(error).__name__}: {error}" + logger.exception(f"Transcription failed: {error}") finally: - if self.cfg.pause_music and self.was_playing: - subprocess.run(["nowplaying-cli", "play"]) + if temp_path: + Path(temp_path).unlink(missing_ok=True) + self._restore_media() + if not self.stopping: + self._set_state(next_state, error_message) self.processing = False - self._set_state("idle") def paste_to_cursor(self, text: str) -> None: - """Copy, paste at cursor, restore the previous clipboard.""" - original = subprocess.run(["pbpaste"], capture_output=True, text=True).stdout - subprocess.run(["pbcopy"], input=text, text=True) - subprocess.run(["osascript", "-e", 'tell application "System Events" to keystroke "v" using command down']) - time.sleep(0.15) - subprocess.run(["pbcopy"], input=original, text=True) + """Paste at the cursor, preserving every native pasteboard representation.""" + from AppKit import NSPasteboard, NSPasteboardItem, NSPasteboardTypeString + + pasteboard = NSPasteboard.generalPasteboard() + saved = [] + for item in pasteboard.pasteboardItems() or []: + values = [] + for kind in item.types(): + if data := item.dataForType_(kind): + values.append((kind, data)) + saved.append(values) + try: + pasteboard.clearContents() + if not pasteboard.setString_forType_(text, NSPasteboardTypeString): + raise RuntimeError("could not write to the clipboard") + subprocess.run( + [ + "osascript", + "-e", + 'tell application "System Events" to keystroke "v" using command down', + ], + check=True, + ) + # Give slower targets time to consume the synthetic paste before restoration. + time.sleep(0.3) + finally: + pasteboard.clearContents() + restored = [] + for values in saved: + item = NSPasteboardItem.alloc().init() + for kind, data in values: + item.setData_forType_(data, kind) + restored.append(item) + if restored: + pasteboard.writeObjects_(restored) # --- run loop ----------------------------------------------------------- def on_press(self, key): @@ -208,47 +337,99 @@ def on_release(self, key): self.stop_recording() def warmup(self): - logger.info("Loading models...") + logger.info("Loading transcription model...") + self._set_state("loading-stt") t0 = time.perf_counter() self.stt = backends.make_stt(self.cfg.backend, self.cfg.stt_model) + temp_path = None with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: - wavfile.write(f.name, self.cfg.sample_rate, np.zeros(self.cfg.sample_rate, dtype=np.int16)) - self.stt.transcribe(f.name) - logger.success(f"{self.cfg.backend} ready ({time.perf_counter()-t0:.1f}s)") + wavfile.write( + f.name, + self.cfg.sample_rate, + np.zeros(self.cfg.sample_rate, dtype=np.int16), + ) + temp_path = f.name + try: + self.stt.transcribe(temp_path) + finally: + Path(temp_path).unlink(missing_ok=True) + logger.success(f"{self.cfg.backend} ready ({time.perf_counter() - t0:.1f}s)") if self.cfg.cleanup_enabled: + self._set_state("loading-cleanup") t0 = time.perf_counter() - self.cleaner = backends.make_cleanup(self.cfg.cleanup_engine, self.cfg.cleanup_model, self.cfg.ollama_url) + self.cleaner = backends.make_cleanup( + self.cfg.cleanup_engine, self.cfg.cleanup_model, self.cfg.ollama_url + ) try: self.cleaner.cleanup("hi", self.cfg.mode) - logger.success(f"cleanup ({self.cfg.cleanup_engine}) ready ({time.perf_counter()-t0:.1f}s)") + logger.success( + f"cleanup ({self.cfg.cleanup_engine}) ready ({time.perf_counter() - t0:.1f}s)" + ) except Exception as e: - logger.warning(f"cleanup warmup failed ({e}); will retry per transcription") + logger.warning( + f"cleanup warmup failed ({e}); will retry per transcription" + ) def run(self): from pynput import keyboard - if (other := config.read_status()) is not None: - logger.error(f"v2t already running (pid {other['pid']}). Stop it first: v2t stop") + try: + self.instance_lock = config.acquire_instance_lock() + except BlockingIOError: + logger.error("v2t already running. Stop it first: v2t stop") sys.exit(1) - config.history_path().parent.mkdir(parents=True, exist_ok=True) # so 'Open history' always works + config.clear_last_error() + config.ensure_dirs() self.hotkey = _resolve_hotkey(self.cfg.hotkey) - self._set_state("starting") # instant feedback before the slow model warmup - self.warmup() - self._set_state("idle") - signal.signal(signal.SIGTERM, lambda *_: (self._clear_status(), sys.exit(0))) - - logger.info(f"Voice-to-Text — {self.cfg.backend} · {self.cfg.mode}") - if self.cfg.pause_music: - logger.info("Pause Music — on") - logger.info(f"Hold {self.cfg.hotkey} to record, release to transcribe and paste. Ctrl+C to quit.") + signal.signal(signal.SIGTERM, self._handle_signal) try: - with keyboard.Listener(on_press=self.on_press, on_release=self.on_release) as listener: + self.warmup() + self._set_state("idle") + logger.info(f"Voice-to-Text — {self.cfg.backend} · {self.cfg.mode}") + if self.cfg.pause_music: + logger.info("Pause Music — on") + logger.info( + f"Hold {self.cfg.hotkey} to record, release to transcribe and paste. Ctrl+C to quit." + ) + with keyboard.Listener( + on_press=self.on_press, on_release=self.on_release + ) as listener: listener.join() except KeyboardInterrupt: logger.info("Shutting down...") + except SystemExit as error: + if error.code not in (None, 0): + config.write_last_error(f"v2t stopped with an error: {error}") + raise + except Exception as error: + config.write_last_error(f"v2t stopped with an error: {error}") + raise finally: - self._clear_status() + self.shutdown() + + def _handle_signal(self, *_): + self.shutdown() + raise SystemExit(0) + + def shutdown(self) -> None: + if self.stopping: + return + self.stopping = True + self.recording = False + self._close_stream() + self._restore_media() + if ( + self.worker is not None + and self.worker.is_alive() + and self.worker is not threading.current_thread() + ): + self._set_state("stopping") + self.worker.join() + self._clear_status() + if self.instance_lock is not None: + self.instance_lock.close() + self.instance_lock = None if __name__ == "__main__": diff --git a/v2t/backends.py b/v2t/backends.py index 7f135fb..bff83c3 100644 --- a/v2t/backends.py +++ b/v2t/backends.py @@ -22,7 +22,9 @@ def __init__(self, model: str = ""): try: from parakeet_mlx import from_pretrained except ImportError as e: - raise SystemExit("parakeet-mlx missing — reinstall voice2text (Apple Silicon only).") from e + raise SystemExit( + "parakeet-mlx missing — reinstall voice2text (Apple Silicon only)." + ) from e self.model = from_pretrained(model or self.default_model) def transcribe(self, wav_path: str) -> str: @@ -36,12 +38,16 @@ def __init__(self, model: str = ""): try: import mlx_whisper except ImportError as e: - raise SystemExit("whisper backend needs: uv tool install 'voice2text[whisper]'") from e + raise SystemExit( + "whisper backend needs: uv tool install 'voice2text[whisper]'" + ) from e self._mlx_whisper = mlx_whisper self.model = model or self.default_model def transcribe(self, wav_path: str) -> str: - return self._mlx_whisper.transcribe(wav_path, path_or_hf_repo=self.model)["text"].strip() + return self._mlx_whisper.transcribe(wav_path, path_or_hf_repo=self.model)[ + "text" + ].strip() STT = {"parakeet": ParakeetSTT, "whisper": WhisperSTT} @@ -81,19 +87,30 @@ def __init__(self, model: str = "", url: str = ""): try: from mlx_lm import load, stream_generate except ImportError as e: - raise SystemExit("mlx-lm missing — reinstall voice2text (Apple Silicon only).") from e + raise SystemExit( + "mlx-lm missing — reinstall voice2text (Apple Silicon only)." + ) from e self._stream = stream_generate self.model_id = model or self.default_model self.model, self.tokenizer = load(self.model_id) def cleanup(self, text: str, mode: str = "strict"): messages = [{"role": "user", "content": PROMPTS[mode].format(text=text)}] - prompt = self.tokenizer.apply_chat_template(messages, add_generation_prompt=True) + prompt = self.tokenizer.apply_chat_template( + messages, add_generation_prompt=True + ) + max_tokens = min(max(400, len(self.tokenizer.encode(text)) + 128), 4096) t0, ttft, parts = time.perf_counter(), None, [] - for resp in self._stream(self.model, self.tokenizer, prompt, max_tokens=400): + for resp in self._stream( + self.model, self.tokenizer, prompt, max_tokens=max_tokens + ): if ttft is None: ttft = time.perf_counter() - t0 parts.append(resp.text) + if len(parts) >= max_tokens: + raise RuntimeError( + "cleanup hit its token limit; using the raw transcription" + ) return "".join(parts).strip(), ttft, time.perf_counter() - t0 @@ -110,16 +127,20 @@ def cleanup(self, text: str, mode: str = "strict", timeout: int = 60): prompt = PROMPTS[mode].format(text=text) req = urllib.request.Request( f"{self.url}/api/generate", - data=json.dumps({"model": self.model_id, "prompt": prompt, "stream": True}).encode(), + data=json.dumps( + {"model": self.model_id, "prompt": prompt, "stream": True} + ).encode(), headers={"Content-Type": "application/json"}, ) t0, ttft, parts = time.perf_counter(), None, [] - with urllib.request.urlopen(req, timeout=timeout) as r: # needs a running ollama server + with urllib.request.urlopen( + req, timeout=timeout + ) as r: # needs a running ollama server for line in r: if not line.strip(): continue chunk = json.loads(line) - if (piece := chunk.get("response", "")): + if piece := chunk.get("response", ""): if ttft is None: ttft = time.perf_counter() - t0 parts.append(piece) @@ -135,7 +156,9 @@ def cleanup(self, text: str, mode: str = "strict", timeout: int = 60): def make_cleanup(engine: str, model: str = "", url: str = "http://localhost:11434"): if engine not in CLEANUP: - raise SystemExit(f"unknown cleanup engine {engine!r}; choose: {', '.join(CLEANUP)}") + raise SystemExit( + f"unknown cleanup engine {engine!r}; choose: {', '.join(CLEANUP)}" + ) return CLEANUP[engine](model, url) @@ -162,7 +185,10 @@ def short_model(name: str) -> str: assert short_model("mlx-community/parakeet-tdt-0.6b-v3") == "parakeet-v3" assert short_model("mlx-community/Qwen3-4B-Instruct-2507-4bit") == "Qwen3-4B" assert short_model("custom/unknown") == "unknown" - assert "filler" in PROMPTS["strict"] and "Keep the original phrasing" in PROMPTS["casual"] + assert ( + "filler" in PROMPTS["strict"] + and "Keep the original phrasing" in PROMPTS["casual"] + ) for fn in (make_stt, make_cleanup): try: fn("bogus") diff --git a/v2t/bench.py b/v2t/bench.py index 3a179e5..89dc279 100644 --- a/v2t/bench.py +++ b/v2t/bench.py @@ -1,10 +1,9 @@ """Benchmark STT and cleanup models -> a markdown grid. Each table is models-as-columns. STT and the default mlx cleanup need Apple Silicon; -the ollama cleanup engine needs Ollama. Results are written to -benchmarks/results/-.md so you can build a grid across machines: run on -each Mac, commit the file. Absolute times reflect THAT machine — compare models -within a run, not across machines. +the ollama cleanup engine needs Ollama. Results are written under +~/.v2t/benchmarks/results/ so you can collect a grid across machines. Absolute +times reflect THAT machine — compare models within a run, not across machines. v2t bench # both tables, default models, samples via `say` v2t bench --cleanup # cleanup table only @@ -17,6 +16,7 @@ import platform import statistics import subprocess +import sys import time import wave from datetime import date @@ -37,15 +37,21 @@ # (name, text) — `say` turns these into audio so the STT bench is self-contained. SAY_SAMPLES = [ ("short", "Hey, can you send me the report by end of day? Thanks a lot."), - ("medium", "So basically I was thinking we could try the other approach for the migration, " - "move the read traffic over first, watch the error rates for a day, and then cut the " - "writes once we're confident nothing is on fire."), - ("long", "Right, so the plan for next quarter. First, we finish the local transcription work, " - "because the cloud dependency keeps breaking behind the corporate VPN and it's just not " - "worth fighting. Second, we ship the menu bar toggle so it's one click to start and stop. " - "Third, we benchmark every model on each machine, write the numbers down, and stop guessing " - "which one is fastest. None of this is hard, it just needs doing, and I'd rather spend the " - "time once than keep re-litigating it every single week in standup."), + ( + "medium", + "So basically I was thinking we could try the other approach for the migration, " + "move the read traffic over first, watch the error rates for a day, and then cut the " + "writes once we're confident nothing is on fire.", + ), + ( + "long", + "Right, so the plan for next quarter. First, we finish the local transcription work, " + "because the cloud dependency keeps breaking behind the corporate VPN and it's just not " + "worth fighting. Second, we ship the menu bar toggle so it's one click to start and stop. " + "Third, we benchmark every model on each machine, write the numbers down, and stop guessing " + "which one is fastest. None of this is hard, it just needs doing, and I'd rather spend the " + "time once than keep re-litigating it every single week in standup.", + ), ] # Filler-laden raw transcriptions for the cleanup table. @@ -58,8 +64,12 @@ def host_label() -> str: if platform.system() == "Darwin": + def sysctl(key): - return subprocess.run(["sysctl", "-n", key], capture_output=True, text=True).stdout.strip() + return subprocess.run( + ["sysctl", "-n", key], capture_output=True, text=True + ).stdout.strip() + chip = sysctl("machdep.cpu.brand_string") or sysctl("hw.model") or "mac" return chip.replace(" ", "-") return platform.machine() + "-" + platform.system().lower() @@ -80,7 +90,17 @@ def ensure_samples(directory: Path) -> list[tuple[str, Path, float]]: aiff = directory / f"{name}.aiff" subprocess.run(["say", "-o", str(aiff), text], check=True) subprocess.run( - ["afconvert", "-f", "WAVE", "-d", "LEI16@16000", "-c", "1", str(aiff), str(wav)], + [ + "afconvert", + "-f", + "WAVE", + "-d", + "LEI16@16000", + "-c", + "1", + str(aiff), + str(wav), + ], check=True, ) aiff.unlink(missing_ok=True) @@ -93,16 +113,23 @@ def _median(xs: list[float]) -> float: def bench_stt(model_specs: list[str], samples, repeat: int) -> dict: - """{spec: {"load": s, sample_name: (proc_s, rtf)}} — model loaded once, reused.""" + """{spec: {"load": s, sample_name: (proc_s, rtf)} | None}.""" results = {} for spec in model_specs: backend, _, model = spec.partition(":") print(f" STT {spec} ... loading") t0 = time.perf_counter() - stt = backends.make_stt(backend, model) + try: + stt = backends.make_stt(backend, model) + except (Exception, SystemExit) as error: + print(f" skipped ({error})") + results[spec] = None + continue results[spec] = {"load": time.perf_counter() - t0} for name, wav, dur in samples: - stt.transcribe(str(wav)) # warm up: MLX compiles per input shape; the app warms up too + stt.transcribe( + str(wav) + ) # warm up: MLX compiles per input shape; the app warms up too times = [] for _ in range(repeat): t = time.perf_counter() @@ -110,7 +137,7 @@ def bench_stt(model_specs: list[str], samples, repeat: int) -> dict: times.append(time.perf_counter() - t) proc = _median(times) results[spec][name] = (proc, dur / proc if proc else 0.0) - print(f" {name} ({dur:.1f}s): {proc:.2f}s RTF {dur/proc:.0f}x") + print(f" {name} ({dur:.1f}s): {proc:.2f}s RTF {dur / proc:.0f}x") return results @@ -135,7 +162,9 @@ def bench_cleanup(specs: list[str], samples: list[str], repeat: int, url: str) - ttfts.append(ttft if ttft is not None else total) totals.append(total) results[spec][i] = (_median(ttfts), _median(totals)) - print(f" sample {i}: ttft {results[spec][i][0]:.2f}s total {results[spec][i][1]:.2f}s") + print( + f" sample {i}: ttft {results[spec][i][0]:.2f}s total {results[spec][i][1]:.2f}s" + ) return results @@ -148,12 +177,27 @@ def label(s: str) -> str: head = "| sample | " + " | ".join(label(s) for s in specs) + " |" sep = "|---|" + "|".join("--:" for _ in specs) + "|" - rows = ["| load | " + " | ".join(f"{results[s]['load']:.1f}s" for s in specs) + " |"] + rows = [ + "| load | " + + " | ".join( + "n/a" if results[s] is None else f"{results[s]['load']:.1f}s" for s in specs + ) + + " |" + ] for name, _wav, dur in samples: - cells = " | ".join(f"{results[s][name][0]:.2f}s ({results[s][name][1]:.0f}x)" for s in specs) + cells = " | ".join( + "n/a" + if results[s] is None + else f"{results[s][name][0]:.2f}s ({results[s][name][1]:.0f}x)" + for s in specs + ) rows.append(f"| {name} ({dur:.1f}s) | {cells} |") note = "\n_Cells: transcription time (real-time factor). Higher RTF = faster._" - return "\n".join(["**Speech-to-text** — time & RTF", "", head, sep, *rows]) + "\n" + note + return ( + "\n".join(["**Speech-to-text** — time & RTF", "", head, sep, *rows]) + + "\n" + + note + ) def md_cleanup_table(results: dict, samples: list[str]) -> str: @@ -169,20 +213,32 @@ def cell(spec: str, i: int) -> str: head = "| sample | " + " | ".join(label(s) for s in specs) + " |" sep = "|---|" + "|".join("--:" for _ in specs) + "|" - rows = [f"| {i} | " + " | ".join(cell(s, i) for s in specs) + " |" for i in range(len(samples))] + rows = [ + f"| {i} | " + " | ".join(cell(s, i) for s in specs) + " |" + for i in range(len(samples)) + ] med = [] for s in specs: r = results[s] - med.append("n/a" if r is None else - f"{_median([r[i][0] for i in range(len(samples))]):.2f} / " - f"{_median([r[i][1] for i in range(len(samples))]):.2f}s") + med.append( + "n/a" + if r is None + else f"{_median([r[i][0] for i in range(len(samples))]):.2f} / " + f"{_median([r[i][1] for i in range(len(samples))]):.2f}s" + ) rows.append("| **median** | " + " | ".join(med) + " |") note = "\n_Cells: time-to-first-token / total. Lower is better._" - return "\n".join(["**Text cleanup** — TTFT & total", "", head, sep, *rows]) + "\n" + note + return ( + "\n".join(["**Text cleanup** — TTFT & total", "", head, sep, *rows]) + + "\n" + + note + ) def write_results(body: str, out: Path | None) -> Path: - out = out or (Path("benchmarks/results") / f"{date.today()}-{host_label()}.md") + out = out or ( + config.home() / "benchmarks/results" / f"{date.today()}-{host_label()}.md" + ) out.parent.mkdir(parents=True, exist_ok=True) header = f"# v2t benchmark — {host_label()} — {date.today()}\n\n_Compare models within this file; absolute times are machine-specific._\n\n" out.write_text(header + body + "\n") @@ -190,13 +246,28 @@ def write_results(body: str, out: Path | None) -> Path: def main(argv: list[str]) -> int: + config.ensure_dirs() p = argparse.ArgumentParser(prog="v2t bench") p.add_argument("--stt", action="store_true", help="run the STT table only") p.add_argument("--cleanup", action="store_true", help="run the cleanup table only") - p.add_argument("--stt-models", nargs="+", default=STT_MODELS, help="backend:model specs") - p.add_argument("--cleanup-models", nargs="+", default=CLEANUP_MODELS, help="engine:model specs (mlx:… or ollama:…)") - p.add_argument("--repeat", type=int, default=3, help="runs per cell, median reported") - p.add_argument("--audio", type=Path, default=config.home() / "bench-audio", help="sample wav dir") + p.add_argument( + "--stt-models", nargs="+", default=STT_MODELS, help="backend:model specs" + ) + p.add_argument( + "--cleanup-models", + nargs="+", + default=CLEANUP_MODELS, + help="engine:model specs (mlx:… or ollama:…)", + ) + p.add_argument( + "--repeat", type=int, default=3, help="runs per cell, median reported" + ) + p.add_argument( + "--audio", + type=Path, + default=config.home() / "bench-audio", + help="sample wav dir", + ) p.add_argument("--url", default="http://localhost:11434", help="ollama base url") p.add_argument("--out", type=Path, help="results markdown path") a = p.parse_args(argv) @@ -208,10 +279,17 @@ def main(argv: list[str]) -> int: if do_stt: print("Speech-to-text:") samples = ensure_samples(a.audio) - sections.append(md_stt_table(bench_stt(a.stt_models, samples, a.repeat), samples)) + sections.append( + md_stt_table(bench_stt(a.stt_models, samples, a.repeat), samples) + ) if do_cleanup: print("Text cleanup:") - sections.append(md_cleanup_table(bench_cleanup(a.cleanup_models, CLEANUP_SAMPLES, a.repeat, a.url), CLEANUP_SAMPLES)) + sections.append( + md_cleanup_table( + bench_cleanup(a.cleanup_models, CLEANUP_SAMPLES, a.repeat, a.url), + CLEANUP_SAMPLES, + ) + ) out = write_results("\n\n".join(sections), a.out) print(f"\nwrote {out}") @@ -220,14 +298,4 @@ def main(argv: list[str]) -> int: if __name__ == "__main__": - # ponytail: check the pure table builders with fake data (no models needed). - fake = { - "parakeet:x/parakeet-tdt-0.6b-v3": {"load": 1.2, "short": (0.10, 60.0)}, - "whisper:x/whisper-large-v3-turbo": {"load": 3.4, "short": (0.50, 12.0)}, - } - t = md_stt_table(fake, [("short", Path("x"), 6.0)]) - assert "0.10s (60x)" in t and "load" in t, t - fakec = {"mlx:x/Qwen3-4B-Instruct-2507-4bit": {0: (0.2, 0.9)}, "ollama:qwen3:4b": None} - c = md_cleanup_table(fakec, ["x"]) - assert "0.20 / 0.90s" in c and "n/a" in c and "median" in c, c - print("bench.py: table-builder checks passed") + raise SystemExit(main(sys.argv[1:])) diff --git a/v2t/cli.py b/v2t/cli.py index 20c66dc..9e2a318 100644 --- a/v2t/cli.py +++ b/v2t/cli.py @@ -1,11 +1,12 @@ """v2t command line. - v2t run push-to-talk (default) - v2t setup guided first-run config (pick models, detect Ollama) - v2t bench benchmark STT + cleanup models +v2t run push-to-talk (default) +v2t setup guided first-run config (pick models, detect Ollama) v2t config show resolved config + paths (--init to write a template) v2t status live state line (off / starting / idle / recording / …) v2t stop stop a running v2t + v2t service install/control an optional launch-at-login service + v2t swiftbar install/update the bundled SwiftBar plugin """ from __future__ import annotations @@ -13,7 +14,9 @@ import argparse import os import signal +import subprocess import sys +from pathlib import Path from . import config @@ -21,14 +24,36 @@ def cmd_run(argv: list[str]) -> int: p = argparse.ArgumentParser(prog="v2t", description="push-to-talk voice-to-text") p.add_argument("--config", help="path to a config.toml") - p.add_argument("--backend", choices=["parakeet", "whisper"], help="override transcription backend") + p.add_argument( + "--backend", + choices=["parakeet", "whisper"], + help="override transcription backend", + ) p.add_argument("--model", help="override STT model") - p.add_argument("--cleanup-engine", choices=["mlx", "ollama"], help="override cleanup engine") + p.add_argument( + "--cleanup-engine", choices=["mlx", "ollama"], help="override cleanup engine" + ) p.add_argument("--cleanup-model", help="override cleanup model") - p.add_argument("--no-cleanup", action="store_true", help="paste raw transcription, skip LLM cleanup") - p.add_argument("--casual", action="store_true", help="light cleanup (punctuation + fillers only)") - p.add_argument("--strict", action="store_true", help="full cleanup (restructures) — the default") - p.add_argument("--pause-music", action="store_true", help="pause media while recording (needs nowplaying-cli)") + p.add_argument( + "--no-cleanup", + action="store_true", + help="paste raw transcription, skip LLM cleanup", + ) + p.add_argument( + "--casual", + action="store_true", + help="light cleanup (punctuation + fillers only)", + ) + p.add_argument( + "--strict", + action="store_true", + help="full cleanup (restructures) — the default", + ) + p.add_argument( + "--pause-music", + action="store_true", + help="pause media while recording (needs nowplaying-cli)", + ) a = p.parse_args(argv) if a.config: @@ -48,7 +73,10 @@ def cmd_run(argv: list[str]) -> int: if cfg.pause_music and not _which("nowplaying-cli"): from loguru import logger - logger.warning("nowplaying-cli not found (brew install nowplaying-cli) — music pause disabled.") + + logger.warning( + "nowplaying-cli not found (brew install nowplaying-cli) — music pause disabled." + ) cfg.pause_music = False app.check_and_request_permissions() app.VoiceToText(cfg).run() @@ -57,7 +85,9 @@ def cmd_run(argv: list[str]) -> int: def cmd_config(argv: list[str]) -> int: p = argparse.ArgumentParser(prog="v2t config") - p.add_argument("--init", action="store_true", help="write a commented config.toml if absent") + p.add_argument( + "--init", action="store_true", help="write a commented config.toml if absent" + ) p.add_argument("--path", action="store_true", help="print paths only") a = p.parse_args(argv) @@ -65,10 +95,13 @@ def cmd_config(argv: list[str]) -> int: print(f"wrote {config.write_default()}") return 0 print(f"home: {config.home()}") - print(f"config: {config.config_path()}{'' if config.config_path().exists() else ' (using defaults; v2t config --init to create)'}") + print( + f"config: {config.config_path()}{'' if config.config_path().exists() else ' (using defaults; v2t config --init to create)'}" + ) print(f"history: {config.history_path()}") if not a.path: from dataclasses import asdict + print("\n[effective config]") for k, v in asdict(config.load()).items(): print(f" {k} = {v!r}") @@ -114,67 +147,161 @@ def cmd_setup(argv: list[str]) -> int: print("keeping existing config.") return 0 - backend = _ask("Transcription model:", [ - ("parakeet", "Parakeet — fast, multilingual (recommended)"), - ("whisper", "Whisper — best for rare languages / accents"), - ]) + backend = _ask( + "Transcription model:", + [ + ("parakeet", "Parakeet — fast, multilingual (recommended)"), + ("whisper", "Whisper — best for rare languages / accents"), + ], + ) engine = "mlx" - cleanup_enabled = _yesno("\nAdd an LLM cleanup pass (punctuation, fillers)?", default=True) + cleanup_enabled = _yesno( + "\nAdd an LLM cleanup pass (punctuation, fillers)?", default=True + ) if cleanup_enabled: if which("ollama"): - engine = _ask("\nOllama detected. Cleanup engine:", [ - ("mlx", "mlx-lm — in-process, no daemon (recommended)"), - ("ollama", "Ollama — reuse what you already run"), - ]) + engine = _ask( + "\nOllama detected. Cleanup engine:", + [ + ("mlx", "mlx-lm — in-process, no daemon (recommended)"), + ("ollama", "Ollama — reuse what you already run"), + ], + ) else: print("\nNo Ollama found — using in-process mlx-lm for cleanup.") - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(_SETUP_TOML.format(backend=backend, enabled=str(cleanup_enabled).lower(), engine=engine)) + config.write_config( + _SETUP_TOML.format( + backend=backend, enabled=str(cleanup_enabled).lower(), engine=engine + ), + path, + ) print(f"\nwrote {path}") if cleanup_enabled and engine == "ollama": print("next: ollama pull qwen3:4b-instruct-2507") - print(f"run: v2t (install with: uv tool install 'voice2text[{backend}]')") + package = "voice2text[whisper]" if backend == "whisper" else "voice2text" + print(f"run: v2t (install with: uv tool install {package})") return 0 def cmd_status(argv: list[str]) -> int: - """For the SwiftBar plugin: '\t\t\t' (state is 'off' when not running).""" + """For SwiftBar: state, STT, cleanup, mode, and any error detail.""" from . import backends s = config.read_status() - cfg = config.load() - state = s["state"] if s else "off" - stt = backends.short_model(cfg.stt_model or backends.STT[cfg.backend].default_model) - cleanup = (backends.short_model(cfg.cleanup_model or backends.CLEANUP[cfg.cleanup_engine].default_model) - if cfg.cleanup_enabled else "off") - print(f"{state}\t{stt}\t{cleanup}\t{cfg.mode}") + if s: + state, stt, cleanup, mode, error = ( + s["state"], + s["stt"], + s["cleanup"], + s["mode"], + s.get("error", ""), + ) + else: + cfg = config.load() + error = config.read_last_error() + state = "launch-error" if error else "off" + stt = backends.short_model( + cfg.stt_model or backends.STT[cfg.backend].default_model + ) + cleanup = ( + backends.short_model( + cfg.cleanup_model or backends.CLEANUP[cfg.cleanup_engine].default_model + ) + if cfg.cleanup_enabled + else "off" + ) + mode = cfg.mode + fields = [ + " ".join(str(value).replace("|", "/").split()) + for value in (state, stt, cleanup, mode, error) + ] + print("\t".join(fields)) return 0 def cmd_stop(argv: list[str]) -> int: - s = config.read_status() - if s is None: + pid = config.running_pid() + if pid is None: + config.clear_status() print("not running") return 1 - os.kill(s["pid"], signal.SIGTERM) - print(f"stopped v2t (pid {s['pid']})") + os.kill(pid, signal.SIGTERM) + print(f"stopped v2t (pid {pid})") return 0 -def cmd_bench(argv: list[str]) -> int: - from . import bench - return bench.main(argv) +def cmd_swiftbar(argv: list[str]) -> int: + from shutil import copy2 + + default_dir = Path( + os.environ.get( + "SWIFTBAR_PLUGIN_DIR", + "~/Library/Application Support/SwiftBar/Plugins", + ) + ).expanduser() + parser = argparse.ArgumentParser( + prog="v2t swiftbar", description="install/update the SwiftBar plugin" + ) + parser.add_argument( + "--dir", type=Path, default=default_dir, help="SwiftBar plugins directory" + ) + args = parser.parse_args(argv) + + source = Path(__file__).resolve().parent.parent / "swiftbar" / "v2t.5s.sh" + if not source.exists(): + raise SystemExit("bundled SwiftBar plugin is missing; reinstall voice2text") + args.dir.mkdir(parents=True, exist_ok=True) + destination = args.dir / source.name + copy2(source, destination) + destination.chmod(0o755) + subprocess.run( + ["open", "-g", "swiftbar://refreshallplugins"], check=False, capture_output=True + ) + print(f"installed {destination}") + return 0 + + +def cmd_service(argv: list[str]) -> int: + from . import service + + parser = argparse.ArgumentParser(prog="v2t service") + parser.add_argument( + "action", choices=["install", "start", "stop", "status", "uninstall"] + ) + args = parser.parse_args(argv) + if args.action == "status": + print(service.status()) + return 0 + try: + if args.action == "install": + path = service.install() + print(f"installed and started {path}") + print(f"service executable: {sys.executable}") + return 0 + getattr(service, args.action)() + except RuntimeError as error: + raise SystemExit(f"service {args.action} failed: {error}") from error + print(f"service {args.action} complete") + return 0 def _which(name: str) -> bool: from shutil import which + return which(name) is not None def main(argv: list[str] | None = None) -> int: argv = list(sys.argv[1:] if argv is None else argv) - table = {"setup": cmd_setup, "bench": cmd_bench, "config": cmd_config, "status": cmd_status, "stop": cmd_stop} + table = { + "setup": cmd_setup, + "config": cmd_config, + "status": cmd_status, + "stop": cmd_stop, + "service": cmd_service, + "swiftbar": cmd_swiftbar, + } if argv and argv[0] in table: return table[argv[0]](argv[1:]) return cmd_run(argv[1:] if argv and argv[0] == "run" else argv) diff --git a/v2t/config.py b/v2t/config.py index e97dd02..cca3e8f 100644 --- a/v2t/config.py +++ b/v2t/config.py @@ -11,8 +11,10 @@ from __future__ import annotations +import fcntl import json import os +import tempfile import tomllib from dataclasses import dataclass from datetime import datetime, timezone @@ -21,12 +23,12 @@ @dataclass class Config: - backend: str = "parakeet" # parakeet | whisper - stt_model: str = "" # blank = the backend's own default + backend: str = "parakeet" # parakeet | whisper + stt_model: str = "" # blank = the backend's own default cleanup_enabled: bool = True - cleanup_engine: str = "mlx" # mlx (in-process via mlx-lm) | ollama - cleanup_model: str = "" # blank = the engine's own default - mode: str = "strict" # strict | casual + cleanup_engine: str = "mlx" # mlx (in-process via mlx-lm) | ollama + cleanup_model: str = "" # blank = the engine's own default + mode: str = "strict" # strict | casual hotkey: str = "cmd_r" sample_rate: int = 16000 pause_music: bool = False @@ -44,7 +46,11 @@ def home() -> Path: def config_path() -> Path: - return Path(os.environ["V2T_CONFIG"]).expanduser() if "V2T_CONFIG" in os.environ else home() / "config.toml" + return ( + Path(os.environ["V2T_CONFIG"]).expanduser() + if "V2T_CONFIG" in os.environ + else home() / "config.toml" + ) def history_path() -> Path: @@ -55,30 +61,134 @@ def run_dir() -> Path: return home() / "run" +def _private_dir(path: Path) -> Path: + path.mkdir(parents=True, exist_ok=True, mode=0o700) + path.chmod(0o700) + return path + + +def ensure_dirs() -> None: + _private_dir(home()) + _private_dir(history_path().parent) + _private_dir(run_dir()) + + +def _config_parent(path: Path) -> None: + """Create a config parent privately, without chmodding an existing custom directory.""" + if path.parent == home(): + _private_dir(path.parent) + return + existed = path.parent.exists() + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + if not existed: + path.parent.chmod(0o700) + + +def lock_path() -> Path: + return run_dir() / "v2t.lock" + + +def acquire_instance_lock(): + """Hold the single-instance lock for as long as the returned file stays open.""" + ensure_dirs() + handle = lock_path().open("a+") + os.chmod(handle.name, 0o600) + try: + fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + handle.close() + raise + handle.seek(0) + handle.truncate() + handle.write(str(os.getpid())) + handle.flush() + return handle + + +def running_pid() -> int | None: + """PID holding the instance lock, or None when v2t is not running.""" + path = lock_path() + if not path.exists(): + return None + with path.open("a+") as handle: + try: + fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + handle.seek(0) + raw = handle.read().strip() + return int(raw) if raw.isdigit() and int(raw) > 1 else None + fcntl.flock(handle, fcntl.LOCK_UN) + return None + + +def write_status(data: dict) -> None: + """Atomically write private runtime status for SwiftBar.""" + directory = _private_dir(run_dir()) + fd, temp_name = tempfile.mkstemp(dir=directory) + try: + os.fchmod(fd, 0o600) + with os.fdopen(fd, "w") as stream: + json.dump(data, stream) + os.replace(temp_name, directory / "status.json") + finally: + Path(temp_name).unlink(missing_ok=True) + + +def clear_status() -> None: + (run_dir() / "status.json").unlink(missing_ok=True) + + +def last_error_path() -> Path: + return run_dir() / "last-error" + + +def write_last_error(message: str) -> None: + """Remember a launch failure that happened before the runtime lock existed.""" + ensure_dirs() + path = last_error_path() + path.write_text(" ".join(message.split())) + path.chmod(0o600) + + +def read_last_error() -> str: + try: + return last_error_path().read_text().strip() + except OSError: + return "" + + +def clear_last_error() -> None: + last_error_path().unlink(missing_ok=True) + + def read_status() -> dict | None: - """The running v2t's status (pid, model, mode, state), or None. Cleans a stale file.""" + """The running v2t's status, or None. Cleans stale or malformed state.""" path = run_dir() / "status.json" if not path.exists(): return None try: data = json.loads(path.read_text()) pid = data["pid"] - except (ValueError, KeyError, OSError): + if not isinstance(pid, int) or pid <= 1: + raise ValueError("invalid pid") + except (TypeError, ValueError, KeyError, OSError): + path.unlink(missing_ok=True) return None - try: - os.kill(pid, 0) # signal 0 = liveness probe - except ProcessLookupError: + if running_pid() != pid: path.unlink(missing_ok=True) return None - except PermissionError: - pass # alive but owned by someone else return data # TOML section -> Config field. Flat dataclass, sectioned file: friendlier to edit. _SECTIONS = { "transcription": {"backend": "backend", "model": "stt_model"}, - "cleanup": {"enabled": "cleanup_enabled", "engine": "cleanup_engine", "model": "cleanup_model", "mode": "mode"}, + "cleanup": { + "enabled": "cleanup_enabled", + "engine": "cleanup_engine", + "model": "cleanup_model", + "mode": "mode", + }, "hotkey": {"key": "hotkey"}, "audio": {"sample_rate": "sample_rate"}, "behavior": {"pause_music": "pause_music", "save_history": "save_history"}, @@ -99,9 +209,33 @@ def load(overrides: dict | None = None) -> Config: for field, value in (overrides or {}).items(): if value is not None: setattr(cfg, field, value) + _validate(cfg) return cfg +def _validate(cfg: Config) -> None: + choices = { + "backend": {"parakeet", "whisper"}, + "cleanup_engine": {"mlx", "ollama"}, + "mode": {"strict", "casual"}, + "hotkey": {"cmd_r", "cmd_l", "alt_r", "alt_l", "ctrl_r", "ctrl_l"}, + } + for field, allowed in choices.items(): + value = getattr(cfg, field) + if value not in allowed: + raise SystemExit( + f"invalid {field} {value!r}; choose: {', '.join(sorted(allowed))}" + ) + if not isinstance(cfg.sample_rate, int) or cfg.sample_rate <= 0: + raise SystemExit("audio.sample_rate must be a positive integer") + for field in ("cleanup_enabled", "pause_music", "save_history"): + if not isinstance(getattr(cfg, field), bool): + raise SystemExit(f"{field} must be true or false") + for field in ("stt_model", "cleanup_model", "ollama_url"): + if not isinstance(getattr(cfg, field), str): + raise SystemExit(f"{field} must be a string") + + DEFAULT_TOML = """\ # v2t config — every key is optional; delete what you don't override. @@ -133,18 +267,30 @@ def load(overrides: dict | None = None) -> Config: def write_default(path: Path | None = None) -> Path: """Write a commented template if absent. Never clobbers an existing file.""" path = path or config_path() - path.parent.mkdir(parents=True, exist_ok=True) + _config_parent(path) if not path.exists(): path.write_text(DEFAULT_TOML) + path.chmod(0o600) + return path + + +def write_config(text: str, path: Path | None = None) -> Path: + """Write an explicit user config privately, preserving custom parent permissions.""" + path = path or config_path() + _config_parent(path) + path.write_text(text) + path.chmod(0o600) return path def append_history(record: dict) -> None: - """Append one JSONL line with a UTC timestamp. Best-effort: never breaks a transcription.""" + """Append one private JSONL record.""" path = history_path() - path.parent.mkdir(parents=True, exist_ok=True) + _private_dir(path.parent) record = {"ts": datetime.now(timezone.utc).isoformat(timespec="seconds"), **record} - with path.open("a") as f: + fd = os.open(path, os.O_APPEND | os.O_CREAT | os.O_WRONLY, 0o600) + os.fchmod(fd, 0o600) + with os.fdopen(fd, "a") as f: f.write(json.dumps(record, ensure_ascii=False) + "\n") @@ -172,7 +318,9 @@ def append_history(record: dict) -> None: append_history({"raw": "héllo", "clean": "Hello."}) line = json.loads(history_path().read_text().splitlines()[-1]) - assert line["clean"] == "Hello." and line["ts"].endswith("+00:00"), "history roundtrip" + assert line["clean"] == "Hello." and line["ts"].endswith("+00:00"), ( + "history roundtrip" + ) os.environ.pop("V2T_HOME") os.environ["XDG_CONFIG_HOME"] = d diff --git a/v2t/service.py b/v2t/service.py new file mode 100644 index 0000000..ac1358e --- /dev/null +++ b/v2t/service.py @@ -0,0 +1,159 @@ +"""Optional per-user launchd service for keeping v2t warm between sessions.""" + +from __future__ import annotations + +import os +import plistlib +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +from . import config + +LABEL = "com.lucharo.voice2text" + + +def plist_path() -> Path: + return Path.home() / "Library/LaunchAgents" / f"{LABEL}.plist" + + +def target() -> str: + return f"gui/{os.getuid()}/{LABEL}" + + +def _launchctl(*args: str, check: bool = True) -> subprocess.CompletedProcess: + result = subprocess.run( + ["launchctl", *args], capture_output=True, text=True, check=False + ) + if check and result.returncode: + raise RuntimeError( + result.stderr.strip() or result.stdout.strip() or "launchctl failed" + ) + return result + + +def loaded() -> bool: + return _launchctl("print", target(), check=False).returncode == 0 + + +def service_pid() -> int | None: + """PID owned by launchd for this job, or None when the job is inactive.""" + result = _launchctl("print", target(), check=False) + if result.returncode: + return None + for line in result.stdout.splitlines(): + key, separator, value = line.strip().partition(" = ") + if key == "pid" and separator and value.isdigit(): + return int(value) + return None + + +def plist_data() -> dict: + environment = { + "PATH": os.environ.get( + "PATH", "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" + ), + "V2T_HOME": str(config.home()), + } + if custom_config := os.environ.get("V2T_CONFIG"): + environment["V2T_CONFIG"] = str(Path(custom_config).expanduser()) + log = config.run_dir() / "v2t.log" + return { + "Label": LABEL, + "ProgramArguments": [sys.executable, "-m", "v2t"], + "RunAtLoad": True, + "EnvironmentVariables": environment, + "StandardOutPath": str(log), + "StandardErrorPath": str(log), + "Umask": 0o077, + } + + +def _prepare_log() -> None: + config.ensure_dirs() + log = config.run_dir() / "v2t.log" + if log.exists() and log.stat().st_size > 1_048_576: + log.replace(log.with_suffix(".log.1")) + log.touch() + log.chmod(0o600) + + +def install() -> Path: + if sys.platform != "darwin": + raise SystemExit("the v2t service is macOS-only") + running, managed = config.running_pid(), service_pid() + if running is not None and running != managed: + raise SystemExit( + "v2t is already running directly; stop it before installing the service" + ) + if loaded(): + _launchctl("bootout", target()) + _prepare_log() + path = plist_path() + path.parent.mkdir(parents=True, exist_ok=True) + fd, temp_name = tempfile.mkstemp(dir=path.parent) + try: + os.fchmod(fd, 0o600) + with os.fdopen(fd, "wb") as stream: + plistlib.dump(plist_data(), stream) + os.replace(temp_name, path) + finally: + Path(temp_name).unlink(missing_ok=True) + start() + return path + + +def start() -> None: + path = plist_path() + if not path.exists(): + raise SystemExit("service is not installed; run: v2t service install") + _prepare_log() + running, managed = config.running_pid(), service_pid() + if running is not None and running == managed: + return + if running is not None: + raise SystemExit( + "v2t is already running directly; stop it before starting the service" + ) + config.clear_last_error() + if loaded(): + _launchctl("kickstart", target()) + else: + _launchctl("bootstrap", f"gui/{os.getuid()}", str(path)) + deadline = time.monotonic() + 3 + while time.monotonic() < deadline: + if config.running_pid() is not None: + return + if error := config.read_last_error(): + raise RuntimeError(error) + time.sleep(0.1) + raise RuntimeError(f"service did not start; check {config.run_dir() / 'v2t.log'}") + + +def stop() -> None: + if service_pid() is not None: + _launchctl("kill", "SIGTERM", target()) + + +def uninstall() -> None: + running, managed = config.running_pid(), service_pid() + if running is not None and running != managed: + raise SystemExit( + "v2t is running directly; stop it before uninstalling the service" + ) + if loaded(): + _launchctl("bootout", target()) + plist_path().unlink(missing_ok=True) + + +def status() -> str: + if not plist_path().exists(): + return "not installed" + running, managed = config.running_pid(), service_pid() + if running is not None and running == managed: + return "running" + if running is not None: + return "installed; v2t is running directly" + return "loaded" if loaded() else "installed but unloaded" From b70da8325eb2e0efbf17998c6d6d0b86f4a06d58 Mon Sep 17 00:00:00 2001 From: lucharo Date: Mon, 20 Jul 2026 11:09:20 +0100 Subject: [PATCH 10/38] fix: avoid crashing SwiftBar refresh URLs --- README.md | 4 ++-- swiftbar/v2t.5s.sh | 3 +-- tests/test_smoke.py | 12 +++--------- v2t/app.py | 15 --------------- v2t/cli.py | 4 ---- 5 files changed, 6 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index b06f700..4820994 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,8 @@ enough now that the basics fit in a small Python package on consumer hardware. > **Design tenet — be communicative.** An ergonomic tool is an expressive one: every action gets > immediate, visible feedback. The menu-bar icon tracks live state (off · loading · ready · -> recording · transcribing · cleaning · error) and repaints the instant anything changes, so you -> always know whether the tool heard you. +> recording · transcribing · cleaning · error) on SwiftBar's five-second refresh, so you always +> know whether the tool heard you. ## What you get diff --git a/swiftbar/v2t.5s.sh b/swiftbar/v2t.5s.sh index d6640ea..c7e3871 100755 --- a/swiftbar/v2t.5s.sh +++ b/swiftbar/v2t.5s.sh @@ -10,8 +10,7 @@ # true # # Install: brew install swiftbar, then drop this file in your SwiftBar plugins -# folder (keep the `.5s.sh` suffix — a 5s safety refresh; v2t also pushes an -# instant repaint on every state change, so the icon tracks live activity). +# folder (keep the `.5s.sh` suffix — SwiftBar refreshes the live state every 5s). # # v2t needs three permissions, granted to the app that LAUNCHES it (your terminal, # or SwiftBar if you use "Start v2t"): Microphone (to record), Accessibility + diff --git a/tests/test_smoke.py b/tests/test_smoke.py index daf6eca..3a886b7 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -38,7 +38,6 @@ def test_audio_device_failure_returns_to_error_state(self): mock.patch.object( app.sd, "InputStream", side_effect=RuntimeError("device unavailable") ), - mock.patch.object(app, "_refresh_swiftbar"), mock.patch.object(app.subprocess, "run") as run, ): voice.start_recording() @@ -77,8 +76,7 @@ def test_status_reports_the_running_overrides(self): lock = config.acquire_instance_lock() self.addCleanup(lock.close) - with mock.patch.object(app, "_refresh_swiftbar"): - voice._set_state("idle") + voice._set_state("idle") output = io.StringIO() with contextlib.redirect_stdout(output): cli.cmd_status([]) @@ -120,10 +118,7 @@ def transcribe(path): lock = config.acquire_instance_lock() self.addCleanup(lock.close) - with ( - mock.patch.object(voice, "paste_to_cursor") as paste, - mock.patch.object(app, "_refresh_swiftbar"), - ): + with mock.patch.object(voice, "paste_to_cursor") as paste: voice.process_audio([np.ones((8, 1), dtype=np.float32)], 1.0) paste.assert_called_once_with("Clean words.") @@ -220,8 +215,7 @@ def test_setup_recommends_the_real_parakeet_install(self): def test_swiftbar_command_installs_the_bundled_plugin(self): plugin_dir = Path(self.tempdir.name) / "plugins" - with mock.patch.object(cli.subprocess, "run"): - cli.cmd_swiftbar(["--dir", str(plugin_dir)]) + cli.cmd_swiftbar(["--dir", str(plugin_dir)]) installed = plugin_dir / "v2t.5s.sh" source = Path(cli.__file__).resolve().parent.parent / "swiftbar" / "v2t.5s.sh" diff --git a/v2t/app.py b/v2t/app.py index 5c1708f..a5a38b4 100644 --- a/v2t/app.py +++ b/v2t/app.py @@ -26,18 +26,6 @@ MIC_PANE = "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone" -def _refresh_swiftbar() -> None: - """Best-effort: tell SwiftBar to repaint the menu-bar icon right now.""" - try: - subprocess.run( - ["open", "-g", "swiftbar://refreshplugin?name=v2t"], - check=False, - capture_output=True, - ) - except OSError: - pass - - def _resolve_hotkey(name: str): from pynput import keyboard @@ -72,7 +60,6 @@ def check_and_request_permissions() -> None: names = " + ".join(name for name, _pane in missing) message = f"Grant {names} to the launching app, restart that app, then start v2t again." config.write_last_error(message) - _refresh_swiftbar() logger.error(message) security = "x-apple.systempreferences:com.apple.preference.security" for _name, pane in missing: @@ -121,11 +108,9 @@ def _set_state(self, state: str, error: str = "") -> None: "error": clean_error, } ) - _refresh_swiftbar() def _clear_status(self) -> None: config.clear_status() - _refresh_swiftbar() def _close_stream(self) -> None: if self.stream is not None: diff --git a/v2t/cli.py b/v2t/cli.py index 9e2a318..d6685d0 100644 --- a/v2t/cli.py +++ b/v2t/cli.py @@ -14,7 +14,6 @@ import argparse import os import signal -import subprocess import sys from pathlib import Path @@ -255,9 +254,6 @@ def cmd_swiftbar(argv: list[str]) -> int: destination = args.dir / source.name copy2(source, destination) destination.chmod(0o755) - subprocess.run( - ["open", "-g", "swiftbar://refreshallplugins"], check=False, capture_output=True - ) print(f"installed {destination}") return 0 From 141092162a1682a647647922d2288472a06092f9 Mon Sep 17 00:00:00 2001 From: lucharo Date: Mon, 20 Jul 2026 11:14:39 +0100 Subject: [PATCH 11/38] feat: clarify SwiftBar startup and permissions --- README.md | 6 +-- pixi.lock | 77 +++++++++++++++++++--------- pyproject.toml | 1 + swiftbar/v2t.5s.sh | 33 +++++++++--- tests/test_smoke.py | 48 ++++++++++++++++-- uv.lock | 119 +++++++++++++++++++++++++++++++++++--------- v2t/cli.py | 16 ++++-- v2t/permissions.py | 34 +++++++++++++ 8 files changed, 270 insertions(+), 64 deletions(-) create mode 100644 v2t/permissions.py diff --git a/README.md b/README.md index 4820994..c74e838 100644 --- a/README.md +++ b/README.md @@ -178,9 +178,9 @@ separate app in Privacy & Security, grant it the same three permissions. **Permissions.** v2t needs three grants, given to the app that *launches* it (your terminal, or SwiftBar if you use "Start v2t"): **Microphone** (record), **Accessibility** + **Input Monitoring** -(hotkey + paste). The Permissions submenu opens each pane — grant them, then **restart the launching -app** (macOS only applies the grant on relaunch). If audio comes back silent, that's a missing -Microphone grant: v2t says so and opens the Mic pane. +(hotkey + paste). The Permissions submenu shows the native state of each grant and opens its pane — +grant missing permissions, then **restart the launching app** (macOS only applies the grant on +relaunch). If audio comes back silent, v2t says so and opens the Mic pane. ## Models & benchmarks diff --git a/pixi.lock b/pixi.lock index bd73645..a920827 100644 --- a/pixi.lock +++ b/pixi.lock @@ -34,6 +34,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/03/dc/200013a74641f8774664bbcd2346c695c05c2e300ea792adcb40a293eed0/soxr-1.1.0-cp312-abi3-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0a/4b/861f91a1565d3189ee899e177b915551fb9a7e2ca25414025a8974f04e74/pyobjc_framework_quartz-12.2.1-cp313-cp313-macosx_10_13_universal2.whl - pypi: https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl @@ -50,7 +51,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/34/ae/e3707f6c1bc6f7aa0df600ba8075bfb8a19252140cd595335be60e25f9ee/standard_sunau-3.13.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/34/db/f9ea1a6844b4fa5dfe2312095cd866a1f724cd0905054ab9d5991778ba50/sentencepiece-0.2.2-cp313-cp313-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/39/e4/71b2e3bd03f0404c89b432273d272dc5427185fd9ed828036730bfc9d057/pyobjc_framework_coreaudio-12.2.1-cp313-cp313-macosx_10_13_universal2.whl - pypi: https://files.pythonhosted.org/packages/3e/04/5acd7ae280c5f93b6ac5ef6cdec14eef4c8d1cd91d85b3292989c94d96b1/scikit_learn-1.9.0-cp313-cp313-macosx_12_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/3e/1e/b9b0ddffae66996b8779f1f7958adc9f21c13a0448cd3be8d7fe589b5b0f/pyobjc_core-12.2.1-cp313-cp313-macosx_10_13_universal2.whl - pypi: https://files.pythonhosted.org/packages/40/03/26a383c9e58c213199d1aad1c3d353cfc22d4444ec6d2c0bf8ad02523843/typer-0.27.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/41/95/a00054a006df82bb1b5b8f666ae44a676b259146fadbff90fe654309fefc/mlx_metal-0.29.3-py3-none-macosx_13_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/44/13/7e63cfba8a7452eb756306aa2fd9b37a29a323b672b964b4fdeded9a3f21/scipy-1.16.3-cp313-cp313-macosx_12_0_arm64.whl @@ -78,25 +81,25 @@ environments: - pypi: https://files.pythonhosted.org/packages/8a/a1/8d812e53a5da1687abb10445275d41a8b13adb781bbf7196ddbcf8d88505/lazy_loader-0.5-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/94/35/386550fd60316d1e37eccdda609b074113298f23cef5bddb2049823fe666/dacite-1.9.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl + - pypi: https://files.pythonhosted.org/packages/98/f5/b38da31d9f95770d0f0f9b9724a898d240d6fb630796e04e9682384d086c/pyobjc_framework_avfoundation-12.2.1-cp313-cp313-macosx_10_13_universal2.whl - pypi: https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a3/88/1a3ea5672f4b0a84802ee9891b86743438e7c04eb0b8f8c4e16a42375327/regex-2025.11.3-cp313-cp313-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/ad/31/0c2e734165abb46215797bd830c4bdcb780b699854b15f2b6240515edcc6/pyobjc_framework_cocoa-12.1-cp313-cp313-macosx_10_13_universal2.whl - pypi: https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b5/ba/c63c5786dfee4c3417094c4b00966e61e4a63efecee22cb7b4c0387dda83/librosa-0.11.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/ba/2d/e8f495328101898c16c32ac10e7b14b08ff2c443a756a76fd1271915f097/pyobjc_framework_quartz-12.1-cp313-cp313-macosx_10_13_universal2.whl - pypi: https://files.pythonhosted.org/packages/c3/52/5fbb203394cc852334d1575cc020f6bcec768d2265355984dfd361968f36/standard_aifc-3.13.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/cc/46/68e8e4d926a2f70fed0437047bc3f9fe08af8fe620d94d80656ebc3cfa9b/pyobjc_framework_cocoa-12.2.1-cp313-cp313-macosx_10_13_universal2.whl - pypi: https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d6/2a/9e5beae2961c9d22ce16258f39edae69996ee0167dd4cfe4e771454086c1/pyobjc_framework_coremedia-12.2.1-cp313-cp313-macosx_10_13_universal2.whl - pypi: https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e5/53/913099c91d384e115ea078325efd9a0bc1ea3eb3458c694b4596cbd267f2/mlx_lm-0.29.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e6/ff/3eba7eb0aed4b6fca37125387cd417e8c458e750621fce56d2c541f67fa8/llvmlite-0.46.0-cp313-cp313-macosx_12_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/ec/73/6fd0bb9ce84138c3857f12e9de63bc901852975a092d545f18087a204aa2/platformdirs-4.10.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f4/54/09a3501c74f1ef54a36002534661cb89a0b2f098fcad87fe64f515a0dba7/parakeet_mlx-0.5.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/f4/d2/29e5e536adc07bc3d33dd09f3f7cf844bf7b4981820dc2a91dd810f3c782/pyobjc_core-12.1-cp313-cp313-macosx_10_13_universal2.whl - pypi: https://files.pythonhosted.org/packages/f5/b1/fa7c600e7dceae12e9606c7578cbc9ff1e1ed55844883ee5c92205e86226/safetensors-0.8.0-cp310-abi3-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fc/21/79e42ee836f1010f5fe9e97d2817a006736bd287c15a3674c399190a2e77/pyobjc_framework_applicationservices-12.1-cp313-cp313-macosx_10_13_universal2.whl @@ -296,6 +299,7 @@ packages: - numpy<2.4 - scipy - pynput + - pyobjc-framework-avfoundation ; sys_platform == 'darwin' - loguru - parakeet-mlx - mlx-lm @@ -323,6 +327,14 @@ packages: version: 5.3.1 sha256: f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/0a/4b/861f91a1565d3189ee899e177b915551fb9a7e2ca25414025a8974f04e74/pyobjc_framework_quartz-12.2.1-cp313-cp313-macosx_10_13_universal2.whl + name: pyobjc-framework-quartz + version: 12.2.1 + sha256: 54c9bc7f507192691841ee4eba5bf36990b259df83ac728efed2d7ea1cd021e4 + requires_dist: + - pyobjc-core>=12.2.1 + - pyobjc-framework-cocoa>=12.2.1 + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl name: loguru version: 0.7.3 @@ -492,6 +504,14 @@ packages: - numpy ; extra == 'test' - protobuf ; extra == 'test' requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/39/e4/71b2e3bd03f0404c89b432273d272dc5427185fd9ed828036730bfc9d057/pyobjc_framework_coreaudio-12.2.1-cp313-cp313-macosx_10_13_universal2.whl + name: pyobjc-framework-coreaudio + version: 12.2.1 + sha256: 6fb46bc090edcaefeb70be2c179071e7a758e3375aebd3338b11773e371c3dce + requires_dist: + - pyobjc-core>=12.2.1 + - pyobjc-framework-cocoa>=12.2.1 + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/3e/04/5acd7ae280c5f93b6ac5ef6cdec14eef4c8d1cd91d85b3292989c94d96b1/scikit_learn-1.9.0-cp313-cp313-macosx_12_0_arm64.whl name: scikit-learn version: 1.9.0 @@ -556,6 +576,11 @@ packages: - pooch>=1.8.0 ; extra == 'tests' - conda-lock==3.0.1 ; extra == 'maintenance' requires_python: '>=3.11' +- pypi: https://files.pythonhosted.org/packages/3e/1e/b9b0ddffae66996b8779f1f7958adc9f21c13a0448cd3be8d7fe589b5b0f/pyobjc_core-12.2.1-cp313-cp313-macosx_10_13_universal2.whl + name: pyobjc-core + version: 12.2.1 + sha256: af101222762665a4125157906cb4b23f5d5a63d3851d5e0504f72a1eaaa2cfd2 + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/40/03/26a383c9e58c213199d1aad1c3d353cfc22d4444ec6d2c0bf8ad02523843/typer-0.27.0-py3-none-any.whl name: typer version: 0.27.0 @@ -1285,6 +1310,17 @@ packages: version: 3.4.4 sha256: e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794 requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/98/f5/b38da31d9f95770d0f0f9b9724a898d240d6fb630796e04e9682384d086c/pyobjc_framework_avfoundation-12.2.1-cp313-cp313-macosx_10_13_universal2.whl + name: pyobjc-framework-avfoundation + version: 12.2.1 + sha256: 9291848b0f0f66031bd3af2549e1e0cdf43e4872b9c562bd29f0239c6ef2b75f + requires_dist: + - pyobjc-core>=12.2.1 + - pyobjc-framework-coremedia>=12.2.1 + - pyobjc-framework-coreaudio>=12.2.1 + - pyobjc-framework-cocoa>=12.2.1 + - pyobjc-framework-quartz>=12.2.1 + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl name: markupsafe version: 3.0.3 @@ -1300,13 +1336,6 @@ packages: version: 2025.11.3 sha256: 04d2765516395cf7dda331a244a3282c0f5ae96075f728629287dfa6f76ba70a requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/ad/31/0c2e734165abb46215797bd830c4bdcb780b699854b15f2b6240515edcc6/pyobjc_framework_cocoa-12.1-cp313-cp313-macosx_10_13_universal2.whl - name: pyobjc-framework-cocoa - version: '12.1' - sha256: 5a3dcd491cacc2f5a197142b3c556d8aafa3963011110102a093349017705118 - requires_dist: - - pyobjc-core>=12.1 - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl name: pyyaml version: 6.0.3 @@ -1399,14 +1428,6 @@ packages: version: 1.17.0 sha256: 4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 requires_python: '>=2.7,!=3.0.*,!=3.1.*,!=3.2.*' -- pypi: https://files.pythonhosted.org/packages/ba/2d/e8f495328101898c16c32ac10e7b14b08ff2c443a756a76fd1271915f097/pyobjc_framework_quartz-12.1-cp313-cp313-macosx_10_13_universal2.whl - name: pyobjc-framework-quartz - version: '12.1' - sha256: 629b7971b1b43a11617f1460cd218bd308dfea247cd4ee3842eb40ca6f588860 - requires_dist: - - pyobjc-core>=12.1 - - pyobjc-framework-cocoa>=12.1 - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/c3/52/5fbb203394cc852334d1575cc020f6bcec768d2265355984dfd361968f36/standard_aifc-3.13.0-py3-none-any.whl name: standard-aifc version: 3.13.0 @@ -1414,6 +1435,13 @@ packages: requires_dist: - standard-chunk ; python_full_version >= '3.13' - audioop-lts ; python_full_version >= '3.13' +- pypi: https://files.pythonhosted.org/packages/cc/46/68e8e4d926a2f70fed0437047bc3f9fe08af8fe620d94d80656ebc3cfa9b/pyobjc_framework_cocoa-12.2.1-cp313-cp313-macosx_10_13_universal2.whl + name: pyobjc-framework-cocoa + version: 12.2.1 + sha256: 3b74a78fa7803e547b32e5e8ec1b49987b52fe318383e793bc6cd49b80efbd9f + requires_dist: + - pyobjc-core>=12.2.1 + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl name: tqdm version: 4.67.1 @@ -1430,6 +1458,14 @@ packages: - requests ; extra == 'telegram' - ipywidgets>=6 ; extra == 'notebook' requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/d6/2a/9e5beae2961c9d22ce16258f39edae69996ee0167dd4cfe4e771454086c1/pyobjc_framework_coremedia-12.2.1-cp313-cp313-macosx_10_13_universal2.whl + name: pyobjc-framework-coremedia + version: 12.2.1 + sha256: 45878f686ce8ea1735ce382b34ef3a5852cafe0ae2a27a49c08701f4c3ab830b + requires_dist: + - pyobjc-core>=12.2.1 + - pyobjc-framework-cocoa>=12.2.1 + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl name: shellingham version: 1.5.4 @@ -1485,11 +1521,6 @@ packages: requires_dist: - colorama>=0.4.6 ; extra == 'windows-terminal' requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/f4/d2/29e5e536adc07bc3d33dd09f3f7cf844bf7b4981820dc2a91dd810f3c782/pyobjc_core-12.1-cp313-cp313-macosx_10_13_universal2.whl - name: pyobjc-core - version: '12.1' - sha256: 01c0cf500596f03e21c23aef9b5f326b9fb1f8f118cf0d8b66749b6cf4cbb37a - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/f5/b1/fa7c600e7dceae12e9606c7578cbc9ff1e1ed55844883ee5c92205e86226/safetensors-0.8.0-cp310-abi3-macosx_11_0_arm64.whl name: safetensors version: 0.8.0 diff --git a/pyproject.toml b/pyproject.toml index b775894..1bcae33 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ dependencies = [ "numpy<2.4", "scipy", "pynput", + "pyobjc-framework-AVFoundation; sys_platform == 'darwin'", "loguru", "parakeet-mlx", "mlx-lm", diff --git a/swiftbar/v2t.5s.sh b/swiftbar/v2t.5s.sh index c7e3871..cc8b050 100755 --- a/swiftbar/v2t.5s.sh +++ b/swiftbar/v2t.5s.sh @@ -61,20 +61,37 @@ case "$1" in ;; esac -# `v2t status` -> "\t\t\t\t" +# `v2t status` -> state, models, mode, error, then the three permission states. if ! STATUS="$("$V2T_BIN" status 2>/dev/null)"; then - STATUS="$(printf 'config-error\t?\t?\t?\tv2t status failed — check config or log')" + STATUS="$(printf 'config-error\t?\t?\t?\tv2t status failed — check config or log\tunknown\tunknown\tunknown')" fi STATE="$(printf '%s' "$STATUS" | cut -f1)" STT="$(printf '%s' "$STATUS" | cut -f2)" CLEANUP="$(printf '%s' "$STATUS" | cut -f3)" MODE="$(printf '%s' "$STATUS" | cut -f4)" ERROR="$(printf '%s' "$STATUS" | cut -f5)" +MIC_PERMISSION="$(printf '%s' "$STATUS" | cut -f6)" +AX_PERMISSION="$(printf '%s' "$STATUS" | cut -f7)" +INPUT_PERMISSION="$(printf '%s' "$STATUS" | cut -f8)" [ "$CLEANUP" = "off" ] && CLEAN_LBL="no cleanup" || CLEAN_LBL="clean: $CLEANUP" +[ "$CLEANUP" = "off" ] && START_STEPS=1 || START_STEPS=2 + +permission_item() { + NAME="$1"; STATE="$2"; PANE="$3" + case "$STATE" in + granted) ICON="✓"; LABEL="Granted"; COLOR="#34c759" ;; + not-requested) ICON="○"; LABEL="Not requested"; COLOR="#8e8e93" ;; + denied) ICON="✕"; LABEL="Denied"; COLOR="#ff3b30" ;; + restricted) ICON="✕"; LABEL="Restricted"; COLOR="#ff3b30" ;; + missing) ICON="○"; LABEL="Not granted"; COLOR="#ff9500" ;; + *) ICON="?"; LABEL="Unknown"; COLOR="#8e8e93" ;; + esac + echo "--$ICON $NAME · $LABEL | color=$COLOR bash=/usr/bin/open param0=\"$SEC?$PANE\" terminal=false" +} case "$STATE" in - loading-stt) TITLE="🎙️🟠"; LINE="🟠 Loading transcription model…" ; RUN=1 ;; - loading-cleanup) TITLE="🎙️🟠"; LINE="🟠 Loading cleanup model…" ; RUN=1 ;; + loading-stt) TITLE="🎙️🟠"; LINE="🟠 Starting 1/$START_STEPS · Loading $STT…" ; RUN=1 ;; + loading-cleanup) TITLE="🎙️🟠"; LINE="🟠 Starting 2/2 · Loading $CLEANUP…" ; RUN=1 ;; idle) TITLE="🎙️🟢"; LINE="🟢 Ready ($MODE)" ; RUN=1 ;; recording) TITLE="🎙️🔴"; LINE="🔴 Recording…" ; RUN=1 ;; transcribing) TITLE="🎙️🟡"; LINE="🟡 Transcribing…" ; RUN=1 ;; @@ -97,10 +114,10 @@ else fi [ -f "$SERVICE_PLIST" ] && echo "Start at login: on | color=#8e8e93 size=12" || echo "Start at login: off | color=#8e8e93 size=12" echo "---" -echo "Permissions (grant, then restart the launching app)" -echo "--🎙️ Microphone | bash=/usr/bin/open param0=\"$SEC?Privacy_Microphone\" terminal=false" -echo "--♿ Accessibility | bash=/usr/bin/open param0=\"$SEC?Privacy_Accessibility\" terminal=false" -echo "--⌨️ Input Monitoring | bash=/usr/bin/open param0=\"$SEC?Privacy_ListenEvent\" terminal=false" +echo "Permissions" +permission_item "🎙️ Microphone" "$MIC_PERMISSION" "Privacy_Microphone" +permission_item "♿ Accessibility" "$AX_PERMISSION" "Privacy_Accessibility" +permission_item "⌨️ Input Monitoring" "$INPUT_PERMISSION" "Privacy_ListenEvent" echo "Open config (~/.v2t) | bash=/usr/bin/open param0=\"$V2T_HOME\" terminal=false" echo "Open transcription history | bash=/usr/bin/open param0=\"$V2T_HOME/history\" terminal=false" [ -f "$V2T_HOME/run/v2t.log" ] && echo "Open log | bash=/usr/bin/open param0=\"$V2T_HOME/run/v2t.log\" terminal=false" diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 3a886b7..02eb298 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -16,7 +16,7 @@ import numpy as np -from v2t import app, backends, bench, cli, config, service +from v2t import app, backends, bench, cli, config, permissions, service class V2TSmokeTests(unittest.TestCase): @@ -70,6 +70,34 @@ def test_permission_check_reports_each_missing_native_permission(self): ) self.assertIn("Input Monitoring", config.read_last_error()) + def test_permission_statuses_use_native_macos_checks(self): + application_services = types.SimpleNamespace( + AXIsProcessTrusted=lambda: True, + CGPreflightListenEventAccess=lambda: False, + ) + avfoundation = types.SimpleNamespace( + AVCaptureDevice=types.SimpleNamespace( + authorizationStatusForMediaType_=lambda _media: 3 + ), + AVMediaTypeAudio="audio", + ) + with ( + mock.patch.object(permissions.sys, "platform", "darwin"), + mock.patch.dict( + sys.modules, + { + "ApplicationServices": application_services, + "AVFoundation": avfoundation, + }, + ), + ): + states = permissions.statuses() + + self.assertEqual( + states, + {"microphone": "granted", "accessibility": "granted", "input": "missing"}, + ) + def test_status_reports_the_running_overrides(self): cfg = config.Config(cleanup_enabled=False, mode="casual") voice = app.VoiceToText(cfg) @@ -78,10 +106,24 @@ def test_status_reports_the_running_overrides(self): voice._set_state("idle") output = io.StringIO() - with contextlib.redirect_stdout(output): + with ( + mock.patch.object( + permissions, + "statuses", + return_value={ + "microphone": "granted", + "accessibility": "granted", + "input": "granted", + }, + ), + contextlib.redirect_stdout(output), + ): cli.cmd_status([]) - self.assertEqual(output.getvalue(), "idle\tparakeet-v3\toff\tcasual\t\n") + self.assertEqual( + output.getvalue(), + "idle\tparakeet-v3\toff\tcasual\t\tgranted\tgranted\tgranted\n", + ) def test_recording_cannot_restart_before_processing_begins(self): voice = app.VoiceToText(config.Config(cleanup_enabled=False)) diff --git a/uv.lock b/uv.lock index 6b933c1..f68bd02 100644 --- a/uv.lock +++ b/uv.lock @@ -1439,16 +1439,18 @@ wheels = [ [[package]] name = "pyobjc-core" -version = "12.1" +version = "12.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b8/b6/d5612eb40be4fd5ef88c259339e6313f46ba67577a95d86c3470b951fce0/pyobjc_core-12.1.tar.gz", hash = "sha256:2bb3903f5387f72422145e1466b3ac3f7f0ef2e9960afa9bcd8961c5cbf8bd21", size = 1000532, upload-time = "2025-11-14T10:08:28.292Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/b1/729f7458a63758bd21716648a8abcd9a0c8f2d2e9897763c8a1a1c7fd31b/pyobjc_core-12.2.1.tar.gz", hash = "sha256:7a7b9b018402342cf32bf1956366896350fbe5c0478cb3ef59778f77abed7f07", size = 1063383, upload-time = "2026-06-19T16:19:39.357Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/95/df/d2b290708e9da86d6e7a9a2a2022b91915cf2e712a5a82e306cb6ee99792/pyobjc_core-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c918ebca280925e7fcb14c5c43ce12dcb9574a33cccb889be7c8c17f3bcce8b6", size = 671263, upload-time = "2025-11-14T09:31:35.231Z" }, - { url = "https://files.pythonhosted.org/packages/64/5a/6b15e499de73050f4a2c88fff664ae154307d25dc04da8fb38998a428358/pyobjc_core-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:818bcc6723561f207e5b5453efe9703f34bc8781d11ce9b8be286bb415eb4962", size = 678335, upload-time = "2025-11-14T09:32:20.107Z" }, - { url = "https://files.pythonhosted.org/packages/f4/d2/29e5e536adc07bc3d33dd09f3f7cf844bf7b4981820dc2a91dd810f3c782/pyobjc_core-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:01c0cf500596f03e21c23aef9b5f326b9fb1f8f118cf0d8b66749b6cf4cbb37a", size = 677370, upload-time = "2025-11-14T09:33:05.273Z" }, - { url = "https://files.pythonhosted.org/packages/1b/f0/4b4ed8924cd04e425f2a07269943018d43949afad1c348c3ed4d9d032787/pyobjc_core-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:177aaca84bb369a483e4961186704f64b2697708046745f8167e818d968c88fc", size = 719586, upload-time = "2025-11-14T09:33:53.302Z" }, - { url = "https://files.pythonhosted.org/packages/25/98/9f4ed07162de69603144ff480be35cd021808faa7f730d082b92f7ebf2b5/pyobjc_core-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:844515f5d86395b979d02152576e7dee9cc679acc0b32dc626ef5bda315eaa43", size = 670164, upload-time = "2025-11-14T09:34:37.458Z" }, - { url = "https://files.pythonhosted.org/packages/62/50/dc076965c96c7f0de25c0a32b7f8aa98133ed244deaeeacfc758783f1f30/pyobjc_core-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:453b191df1a4b80e756445b935491b974714456ae2cbae816840bd96f86db882", size = 712204, upload-time = "2025-11-14T09:35:24.148Z" }, + { url = "https://files.pythonhosted.org/packages/92/87/16564ef5e4568ee0edd9e712d8111dc8b67621d6bb6ff430646ee2d637dd/pyobjc_core-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:24b76a63caf0b5369d4a377c7c0438cd70df81539057af3db839bfaa3579e04a", size = 6484662, upload-time = "2026-06-19T16:04:44.979Z" }, + { url = "https://files.pythonhosted.org/packages/8c/88/300ad283bed0c971c52dcac6f70113e138169d4ce6d856ddd03d16081e51/pyobjc_core-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a64232bb27ed101d4adc7d42b0e64a6d3331aac7bee7861c037a6777a163f10b", size = 6433347, upload-time = "2026-06-19T16:04:49.341Z" }, + { url = "https://files.pythonhosted.org/packages/3e/1e/b9b0ddffae66996b8779f1f7958adc9f21c13a0448cd3be8d7fe589b5b0f/pyobjc_core-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:af101222762665a4125157906cb4b23f5d5a63d3851d5e0504f72a1eaaa2cfd2", size = 6436004, upload-time = "2026-06-19T16:04:53.257Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/bd309ede07784c6e5fac4b440c90a5f72a66da7859ed303a9392fe8a5f3f/pyobjc_core-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:efe465e3ecc6fc73f7c7622620345d134a8d34564ab1c29d8247e45f4ed55071", size = 6687044, upload-time = "2026-06-19T16:04:57.42Z" }, + { url = "https://files.pythonhosted.org/packages/bd/8a/cfa4f56939d554dbb342ec6e5226a441e2f552bc2002a0ddf7705bb11bef/pyobjc_core-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2b8fc0531c27277325e113ac00b8a72a82e6145f0a88175b9425d8de814ff69a", size = 6429289, upload-time = "2026-06-19T16:05:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/42/74/446c89bc18103aaa4a00d1fb85ff8acace9a0dc3f362d9678ebf7571e275/pyobjc_core-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9bef500f979e22d54f9da3aaebf6a48f873234b324858bd69256055a318955c7", size = 6690181, upload-time = "2026-06-19T16:05:06.201Z" }, + { url = "https://files.pythonhosted.org/packages/99/c7/0121ee4c616af07ad2de8cd1a286f6978dc9a227eb58b7c2e875cb68a1df/pyobjc_core-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:047c226eeb58a2993ace5e8904e71cc9426ee20d064c617f8fbf32717d37093e", size = 6487078, upload-time = "2026-06-19T16:05:10.093Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a8/cb9fcc150f97d0bf22a2028f88b24cc35949beb1bcc7b8bc5c17d4401677/pyobjc_core-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:1188613805336270279570467e4455b74cb6c0f60913ac74c917ee1c37cfaecb", size = 6733064, upload-time = "2026-06-19T16:05:14.313Z" }, ] [[package]] @@ -1471,21 +1473,86 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/bb/dab21d2210d3ef7dd0616df7e8ea89b5d8d62444133a25f76e649a947168/pyobjc_framework_applicationservices-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1f72e20009a4ebfd5ed5b23dc11c1528ad6b55cc63ee71952ddb2a5e5f1cb7da", size = 33238, upload-time = "2025-11-14T09:36:24.751Z" }, ] +[[package]] +name = "pyobjc-framework-avfoundation" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-framework-coreaudio" }, + { name = "pyobjc-framework-coremedia" }, + { name = "pyobjc-framework-quartz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/26/7616f0bc8e4eaaba948cf5d220c8f55e0f54f617a2812392a82f19c30f39/pyobjc_framework_avfoundation-12.2.1.tar.gz", hash = "sha256:2735e4f1c345d2b533541577e292f3ad2f75d19200eff99f1a2db16d78b4f1a3", size = 410329, upload-time = "2026-06-19T16:19:52.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/88/38ccef918dea4188e0001e644f3bc16e26d11959b04838e5e243798f703a/pyobjc_framework_avfoundation-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d3ce4b2bb2ae51ad5c27f1d2f2a623bef8b443bccc34423b5149df010f9f501c", size = 85536, upload-time = "2026-06-19T16:06:21.308Z" }, + { url = "https://files.pythonhosted.org/packages/49/bb/e427b2fd705e9dabbfa0ab89b863305788906e34064db2bb930f1c3ba216/pyobjc_framework_avfoundation-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f372800274df35f8d964cad0ad6da7636f1b6720e802e28e5a6bc1f16f16f135", size = 85578, upload-time = "2026-06-19T16:06:22.292Z" }, + { url = "https://files.pythonhosted.org/packages/98/f5/b38da31d9f95770d0f0f9b9724a898d240d6fb630796e04e9682384d086c/pyobjc_framework_avfoundation-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9291848b0f0f66031bd3af2549e1e0cdf43e4872b9c562bd29f0239c6ef2b75f", size = 85628, upload-time = "2026-06-19T16:06:23.317Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a4/f1bc5fc239015d8fb4414a83592d69b85b8bbdc68f2403c21dbcdcb8ce12/pyobjc_framework_avfoundation-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:3e92a8502d389469b1307a6a2c386f20d2e77878c7e35e3e14120596b47eb205", size = 86077, upload-time = "2026-06-19T16:06:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/4a/a2/4a65d749d57dd16b034ee0935b7daa1228e8a735bf920e9eab9f4aaa9f1e/pyobjc_framework_avfoundation-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:043aedd86adbc0a3bfe8f76d1a1ce920539e47b5dd5d965358f93e0063c9a53d", size = 85836, upload-time = "2026-06-19T16:06:25.377Z" }, + { url = "https://files.pythonhosted.org/packages/bc/49/b9e8f51821a9e5a4a76bea7ab10c2dc1b51b8a141cf4bccbba022f2db19f/pyobjc_framework_avfoundation-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:50e2b65e5c3845eb8ad0d37868a7f040656fe69e6c03f4282670e7dc52ed7a4a", size = 86173, upload-time = "2026-06-19T16:06:26.423Z" }, + { url = "https://files.pythonhosted.org/packages/e1/3d/eda090c53a98293d6ad78eb2a0639e9ab402c14555c2d9e3c311fc482051/pyobjc_framework_avfoundation-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:3f74e199c933df54020b972b985af5a5d2e63134278ea02dfd81923976500794", size = 85889, upload-time = "2026-06-19T16:06:27.517Z" }, + { url = "https://files.pythonhosted.org/packages/a4/58/88126080195ab3fc95b08562ce30f9f599bdabf282b3a4e57bd2c8c2595c/pyobjc_framework_avfoundation-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:1ec2fdfe6eee51f12e7d933e0109ea8ad553acf4301c01f46b79d0a9d378d705", size = 86232, upload-time = "2026-06-19T16:06:28.535Z" }, +] + [[package]] name = "pyobjc-framework-cocoa" -version = "12.1" +version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/02/a3/16ca9a15e77c061a9250afbae2eae26f2e1579eb8ca9462ae2d2c71e1169/pyobjc_framework_cocoa-12.1.tar.gz", hash = "sha256:5556c87db95711b985d5efdaaf01c917ddd41d148b1e52a0c66b1a2e2c5c1640", size = 2772191, upload-time = "2025-11-14T10:13:02.069Z" } +sdist = { url = "https://files.pythonhosted.org/packages/51/34/fbe38a204643aa4e1b91391cdce07a34da565a69171ebcad08de7438a556/pyobjc_framework_cocoa-12.2.1.tar.gz", hash = "sha256:b94b37fe5730e5ae1fb0052912cd174e6ec329b0bfba4a012ae5db1014b5864b", size = 3125751, upload-time = "2026-06-19T16:20:05.159Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/07/5760735c0fffc65107e648eaf7e0991f46da442ac4493501be5380e6d9d4/pyobjc_framework_cocoa-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f52228bcf38da64b77328787967d464e28b981492b33a7675585141e1b0a01e6", size = 383812, upload-time = "2025-11-14T09:40:53.169Z" }, - { url = "https://files.pythonhosted.org/packages/95/bf/ee4f27ec3920d5c6fc63c63e797c5b2cc4e20fe439217085d01ea5b63856/pyobjc_framework_cocoa-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:547c182837214b7ec4796dac5aee3aa25abc665757b75d7f44f83c994bcb0858", size = 384590, upload-time = "2025-11-14T09:41:17.336Z" }, - { url = "https://files.pythonhosted.org/packages/ad/31/0c2e734165abb46215797bd830c4bdcb780b699854b15f2b6240515edcc6/pyobjc_framework_cocoa-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5a3dcd491cacc2f5a197142b3c556d8aafa3963011110102a093349017705118", size = 384689, upload-time = "2025-11-14T09:41:41.478Z" }, - { url = "https://files.pythonhosted.org/packages/23/3b/b9f61be7b9f9b4e0a6db18b3c35c4c4d589f2d04e963e2174d38c6555a92/pyobjc_framework_cocoa-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:914b74328c22d8ca261d78c23ef2befc29776e0b85555973927b338c5734ca44", size = 388843, upload-time = "2025-11-14T09:42:05.719Z" }, - { url = "https://files.pythonhosted.org/packages/59/bb/f777cc9e775fc7dae77b569254570fe46eb842516b3e4fe383ab49eab598/pyobjc_framework_cocoa-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:03342a60fc0015bcdf9b93ac0b4f457d3938e9ef761b28df9564c91a14f0129a", size = 384932, upload-time = "2025-11-14T09:42:29.771Z" }, - { url = "https://files.pythonhosted.org/packages/58/27/b457b7b37089cad692c8aada90119162dfb4c4a16f513b79a8b2b022b33b/pyobjc_framework_cocoa-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6ba1dc1bfa4da42d04e93d2363491275fb2e2be5c20790e561c8a9e09b8cf2cc", size = 388970, upload-time = "2025-11-14T09:42:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d6/dc66ea8519a0475efbccf73f82cc28066339bb300a27f5e1bf91ab1d7002/pyobjc_framework_cocoa-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:dc6da84f4fc62cc25463bbb85e77a57b8d5ac6caf9a60702daf2edb601332f15", size = 387298, upload-time = "2026-06-19T16:07:37.412Z" }, + { url = "https://files.pythonhosted.org/packages/f7/cf/1b3b32b2f28f66cc053c3438ef4e6df36a1591945bf05e7399da18d74553/pyobjc_framework_cocoa-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:28b9b8bab1c36efb94744786918752d0c1842f5fbb67e7d5ca97b5f736512080", size = 388113, upload-time = "2026-06-19T16:07:38.9Z" }, + { url = "https://files.pythonhosted.org/packages/cc/46/68e8e4d926a2f70fed0437047bc3f9fe08af8fe620d94d80656ebc3cfa9b/pyobjc_framework_cocoa-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3b74a78fa7803e547b32e5e8ec1b49987b52fe318383e793bc6cd49b80efbd9f", size = 388183, upload-time = "2026-06-19T16:07:40.483Z" }, + { url = "https://files.pythonhosted.org/packages/2e/f3/dfc9af4c9eb2e5389c860ad5ef252be9fe456db09f39d537555dc5057aa1/pyobjc_framework_cocoa-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:dc2eaca2f13c7bcd8e41e51a372e47825dea9dd3126108760eed7ba883d2945c", size = 392275, upload-time = "2026-06-19T16:07:42.078Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c8/b90baa8f3592eded79b4be98fb59d2b8dc16b62361e34292bd95806ebd9f/pyobjc_framework_cocoa-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b386c324d64ae565c1f6b7dfb77be68f640a1c7c23caa6966ab661131f519561", size = 388357, upload-time = "2026-06-19T16:07:43.364Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/64a94651b9294702d55e748d94de30e25bc59d0784526be7643f4467eccd/pyobjc_framework_cocoa-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a6c584e2af0813cb2f6103b184e632665a26f58c1bd5b08ffd6e95a19c617f7b", size = 392404, upload-time = "2026-06-19T16:07:44.955Z" }, + { url = "https://files.pythonhosted.org/packages/5c/cc/26e8a7bf1f5e8caa38b7f80d486296f9fd3c97e71ad7e5444ef22e802758/pyobjc_framework_cocoa-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:b6023657b8d6cc049a21bd6b4752425f2f53c42f9f0b02d64c7608cc484bf103", size = 388589, upload-time = "2026-06-19T16:07:46.276Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/eedf743a303ea742b8e082afe3613fb4d6618bc1a48cf2568b004ce906f7/pyobjc_framework_cocoa-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:c685ccd8e266a07cf912a2c5a13b1f2eff2a868a1aff163b4801b4687bd425e1", size = 392691, upload-time = "2026-06-19T16:07:47.477Z" }, +] + +[[package]] +name = "pyobjc-framework-coreaudio" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/df/f1d402bb7b437374f942bb19410a955a74291867c77a920d9c13887d9e48/pyobjc_framework_coreaudio-12.2.1.tar.gz", hash = "sha256:7dfbf1851523aed453af43a628e057d8950d6e020574aa497a2e4f559b6383c8", size = 78690, upload-time = "2026-06-19T16:20:10.586Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/1f/853c498e18db8b6d9eb0cb2261bf8cda3948f2c925d1c820baaf96fdb54d/pyobjc_framework_coreaudio-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:214ce41d1ac3743377f607326d3ff70494dd6f75b2c575fe9c99d3890c545dee", size = 35149, upload-time = "2026-06-19T16:08:10.278Z" }, + { url = "https://files.pythonhosted.org/packages/ea/c1/1fc7a344ac15646fdf78c91af81028113c87375036e505082cf1f90bd657/pyobjc_framework_coreaudio-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:41a0d8c3b3957f35f17370071aeef94e176d07c9e2130d0aeeb4b260117acd52", size = 35415, upload-time = "2026-06-19T16:08:11.329Z" }, + { url = "https://files.pythonhosted.org/packages/39/e4/71b2e3bd03f0404c89b432273d272dc5427185fd9ed828036730bfc9d057/pyobjc_framework_coreaudio-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6fb46bc090edcaefeb70be2c179071e7a758e3375aebd3338b11773e371c3dce", size = 35445, upload-time = "2026-06-19T16:08:12.455Z" }, + { url = "https://files.pythonhosted.org/packages/67/5b/07dc0bf9f79d3b76effba3f0a754be6fda0947848939236820c8e70ac896/pyobjc_framework_coreaudio-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:d05e5fd58c1e5f48e7767b1f6182cf36ee9bff38568544d51f468b25ad90e334", size = 38205, upload-time = "2026-06-19T16:08:13.381Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a4/6495dd34b1ed7a0e8f6d672577da62a92b166a0bc2ded8e55e552cbe5ad2/pyobjc_framework_coreaudio-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:efb79709371f29139f44adc36bd6c32d5a3a0ff3ffd0c5f3f372275cef893b18", size = 36697, upload-time = "2026-06-19T16:08:14.407Z" }, + { url = "https://files.pythonhosted.org/packages/cd/9e/9f0cba5b973a5c8041edb75cf0870f40324437721428b1baceb437553e3f/pyobjc_framework_coreaudio-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9c06b9d7ee3b2a76dd171de519f571d09bdebd85f40b5960d16f711395513cdd", size = 38300, upload-time = "2026-06-19T16:08:15.297Z" }, + { url = "https://files.pythonhosted.org/packages/a1/13/e8df283cdd73dec1b97033be04a89a05e17714c8e2229f2a2e5a44f377c7/pyobjc_framework_coreaudio-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:51be22e4fac73ba4b26ee34c457ac94f56218e4e9c1494f8f4e7dfe9dbb6a15f", size = 36723, upload-time = "2026-06-19T16:08:16.247Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b2/c76ddff169b45d7239468f44bf4af3ff431c014fbf0aa5dbbfcc099672f8/pyobjc_framework_coreaudio-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:18717e6e106556fd4ffd0bdf6a6dae796fc9100c6b373e664437604dd0b9e94d", size = 38333, upload-time = "2026-06-19T16:08:17.264Z" }, +] + +[[package]] +name = "pyobjc-framework-coremedia" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e3/79/f501d730a9c320e0b2b3916e95f57e66dd6736d210a1aa5b63eb6c43e605/pyobjc_framework_coremedia-12.2.1.tar.gz", hash = "sha256:71b45f7cd52bd997d836c15a0e1016db90815a219dc87fd20435a6f08b87df7b", size = 98252, upload-time = "2026-06-19T16:20:15.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/34/8b97b76f0643e01c98d5c246b1ed74884f4b3c43dfff2a36886212e20dfa/pyobjc_framework_coremedia-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:38e03a92a7fdf43162faadf354a8497ae764e3b3be26a8ffb107ed5d743cac76", size = 29513, upload-time = "2026-06-19T16:08:52.69Z" }, + { url = "https://files.pythonhosted.org/packages/af/17/6bf365530573a6b7719b5a47efe6c38ac8c42f6b556babe419f5de48a84c/pyobjc_framework_coremedia-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fd0e25bc704dd91882bc29d682880c42f712b39bdfe3d3168780d24a9d9eaf26", size = 29419, upload-time = "2026-06-19T16:08:53.52Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2a/9e5beae2961c9d22ce16258f39edae69996ee0167dd4cfe4e771454086c1/pyobjc_framework_coremedia-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:45878f686ce8ea1735ce382b34ef3a5852cafe0ae2a27a49c08701f4c3ab830b", size = 29431, upload-time = "2026-06-19T16:08:54.41Z" }, + { url = "https://files.pythonhosted.org/packages/dc/51/00b7f012e55475502296cc8ef276b94ad7d4d10d97ce2e88bf9d87f9b664/pyobjc_framework_coremedia-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9f2b30ef288c9f2fa1599a1cc5868f8cd949c7138a7f15244c7a6884afcef273", size = 29491, upload-time = "2026-06-19T16:08:55.297Z" }, + { url = "https://files.pythonhosted.org/packages/16/dc/7083e6781fcd843d210ec6d247d28c6e1032ea1c5655b9e1182d0a85f331/pyobjc_framework_coremedia-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:84e9fb3f7b6420fc3865a5812604c63546b5777f4999e0e0e830a9a0816e8743", size = 29468, upload-time = "2026-06-19T16:08:56.129Z" }, + { url = "https://files.pythonhosted.org/packages/11/74/edb3ec87d2e5f962c75af990207e4df39b290f11b4f4c3cc446ee141e4e7/pyobjc_framework_coremedia-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:541d64d2fc7576054bdc3508a94f37671e81e5ca1582e16ebec2c0e05dd75412", size = 29519, upload-time = "2026-06-19T16:08:56.956Z" }, + { url = "https://files.pythonhosted.org/packages/28/0f/9176b6a7666e46273c0392c8f77bc812e006dc74a1c21263c4bdf385012f/pyobjc_framework_coremedia-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:2a355770b66b4caf99560c11af44dfeca53066ece75807fc6a391a16369ccc72", size = 29502, upload-time = "2026-06-19T16:08:57.918Z" }, + { url = "https://files.pythonhosted.org/packages/a5/27/13e7d8d16528734690f83abd901cbc53dd6f7c1adbcb3a902166735c6c21/pyobjc_framework_coremedia-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:5536453ba631227810a1141fb1761149244bd6d0e7cd4b12477be3b164f5bb11", size = 29555, upload-time = "2026-06-19T16:08:58.792Z" }, ] [[package]] @@ -1509,20 +1576,22 @@ wheels = [ [[package]] name = "pyobjc-framework-quartz" -version = "12.1" +version = "12.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/94/18/cc59f3d4355c9456fc945eae7fe8797003c4da99212dd531ad1b0de8a0c6/pyobjc_framework_quartz-12.1.tar.gz", hash = "sha256:27f782f3513ac88ec9b6c82d9767eef95a5cf4175ce88a1e5a65875fee799608", size = 3159099, upload-time = "2025-11-14T10:21:24.31Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/f6/2a8b84dbf1fe7c04dd96ea73d991678d4e09a909f51971ecc51629bb2ab4/pyobjc_framework_quartz-12.2.1.tar.gz", hash = "sha256:b3b8b6f71e66147f8ff9e6213864cc8527e3a0b1ee90835b93ce221f4802d9b0", size = 3215521, upload-time = "2026-06-19T16:21:30.199Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ef/dcd22b743e38b3c430fce4788176c2c5afa8bfb01085b8143b02d1e75201/pyobjc_framework_quartz-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:19f99ac49a0b15dd892e155644fe80242d741411a9ed9c119b18b7466048625a", size = 217795, upload-time = "2025-11-14T09:59:46.922Z" }, - { url = "https://files.pythonhosted.org/packages/e9/9b/780f057e5962f690f23fdff1083a4cfda5a96d5b4d3bb49505cac4f624f2/pyobjc_framework_quartz-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7730cdce46c7e985535b5a42c31381af4aa6556e5642dc55b5e6597595e57a16", size = 218798, upload-time = "2025-11-14T10:00:01.236Z" }, - { url = "https://files.pythonhosted.org/packages/ba/2d/e8f495328101898c16c32ac10e7b14b08ff2c443a756a76fd1271915f097/pyobjc_framework_quartz-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:629b7971b1b43a11617f1460cd218bd308dfea247cd4ee3842eb40ca6f588860", size = 219206, upload-time = "2025-11-14T10:00:15.623Z" }, - { url = "https://files.pythonhosted.org/packages/67/43/b1f0ad3b842ab150a7e6b7d97f6257eab6af241b4c7d14cb8e7fde9214b8/pyobjc_framework_quartz-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:53b84e880c358ba1ddcd7e8d5ea0407d760eca58b96f0d344829162cda5f37b3", size = 224317, upload-time = "2025-11-14T10:00:30.703Z" }, - { url = "https://files.pythonhosted.org/packages/4a/00/96249c5c7e5aaca5f688ca18b8d8ad05cd7886ebd639b3c71a6a4cadbe75/pyobjc_framework_quartz-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:42d306b07f05ae7d155984503e0fb1b701fecd31dcc5c79fe8ab9790ff7e0de0", size = 219558, upload-time = "2025-11-14T10:00:45.476Z" }, - { url = "https://files.pythonhosted.org/packages/4d/a6/708a55f3ff7a18c403b30a29a11dccfed0410485a7548c60a4b6d4cc0676/pyobjc_framework_quartz-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0cc08fddb339b2760df60dea1057453557588908e42bdc62184b6396ce2d6e9a", size = 224580, upload-time = "2025-11-14T10:01:00.091Z" }, + { url = "https://files.pythonhosted.org/packages/b9/08/527d1ff856e2f2446b5887be01989cc08f9adaf3de7d4eb13d07826c362f/pyobjc_framework_quartz-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60f29408b4f9ed5391a29c6b63e2aa56ddfb8b66b3fb47962930427981e14462", size = 217998, upload-time = "2026-06-19T16:16:02.978Z" }, + { url = "https://files.pythonhosted.org/packages/14/fc/d7c7b3134cdbd1a487f3f77b5be125d87a6c9e7d9411035739d99335cc0c/pyobjc_framework_quartz-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:de9c8cca7e95290c8d540466af11c7cdfe3a5458e6f56c34006d5b45243f9ed9", size = 219000, upload-time = "2026-06-19T16:16:04.29Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4b/861f91a1565d3189ee899e177b915551fb9a7e2ca25414025a8974f04e74/pyobjc_framework_quartz-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:54c9bc7f507192691841ee4eba5bf36990b259df83ac728efed2d7ea1cd021e4", size = 219403, upload-time = "2026-06-19T16:16:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b5/b27010d2f288737f627f74be6d5549f49c841542365c84b9a3011fe39ce7/pyobjc_framework_quartz-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:bfc0d2badd819823d21df8069dcf9544ce360ed747a8895c51bdb25d8d125f45", size = 224458, upload-time = "2026-06-19T16:16:07.252Z" }, + { url = "https://files.pythonhosted.org/packages/8b/5d/85ffd9d433989205d572a50d625c63b29c05e0c5235a725f15ae1023672c/pyobjc_framework_quartz-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ceb56939c337b36d9d81185ade31f77dc52c85cf79bb16e53e9b32f54b6bb3f5", size = 219769, upload-time = "2026-06-19T16:16:08.814Z" }, + { url = "https://files.pythonhosted.org/packages/e2/d6/b917e4b63d72ea84a27121076f3033f23f6497c0e6ce8d304766c899897f/pyobjc_framework_quartz-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8105c98b798f2bf81c05c54bddeeadbf62f0b5dfec13bd6e719dd2cdf7e1cddf", size = 224717, upload-time = "2026-06-19T16:16:10.215Z" }, + { url = "https://files.pythonhosted.org/packages/04/e2/f3c1ed3228f7430ef5ade23db6f1fcbae99290f177ce5653348fd9e05f4d/pyobjc_framework_quartz-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:bbc214f1a216b5d3651bc832d0ac4589f029f3f37cd6cbb370aac12a7c77942c", size = 219825, upload-time = "2026-06-19T16:16:11.433Z" }, + { url = "https://files.pythonhosted.org/packages/66/2a/2c99a5ad2fe0a11600ea123b8e9a08ff138fcb2ad1e13e376f4bd4aa1d96/pyobjc_framework_quartz-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:ca61624a0b0e6286d8a0f97f47eb9011e4e81e9a339db436d48af527e7065bb1", size = 224770, upload-time = "2026-06-19T16:16:13.035Z" }, ] [[package]] @@ -2412,6 +2481,7 @@ dependencies = [ { name = "numpy" }, { name = "parakeet-mlx" }, { name = "pynput" }, + { name = "pyobjc-framework-avfoundation", marker = "sys_platform == 'darwin'" }, { name = "scipy" }, { name = "sounddevice" }, ] @@ -2436,6 +2506,7 @@ requires-dist = [ { name = "numpy", specifier = "<2.4" }, { name = "parakeet-mlx" }, { name = "pynput" }, + { name = "pyobjc-framework-avfoundation", marker = "sys_platform == 'darwin'" }, { name = "scipy" }, { name = "sounddevice" }, ] diff --git a/v2t/cli.py b/v2t/cli.py index d6685d0..c3e946a 100644 --- a/v2t/cli.py +++ b/v2t/cli.py @@ -184,8 +184,8 @@ def cmd_setup(argv: list[str]) -> int: def cmd_status(argv: list[str]) -> int: - """For SwiftBar: state, STT, cleanup, mode, and any error detail.""" - from . import backends + """For SwiftBar: runtime state, models, error, and native permission grants.""" + from . import backends, permissions s = config.read_status() if s: @@ -211,9 +211,19 @@ def cmd_status(argv: list[str]) -> int: else "off" ) mode = cfg.mode + grants = permissions.statuses() fields = [ " ".join(str(value).replace("|", "/").split()) - for value in (state, stt, cleanup, mode, error) + for value in ( + state, + stt, + cleanup, + mode, + error, + grants["microphone"], + grants["accessibility"], + grants["input"], + ) ] print("\t".join(fields)) return 0 diff --git a/v2t/permissions.py b/v2t/permissions.py new file mode 100644 index 0000000..4452fba --- /dev/null +++ b/v2t/permissions.py @@ -0,0 +1,34 @@ +"""Read the three macOS privacy grants v2t needs, without prompting.""" + +from __future__ import annotations + +import sys + + +def statuses() -> dict[str, str]: + if sys.platform != "darwin": + return {"microphone": "unknown", "accessibility": "unknown", "input": "unknown"} + + from ApplicationServices import AXIsProcessTrusted, CGPreflightListenEventAccess + + accessibility = "granted" if AXIsProcessTrusted() else "missing" + input_monitoring = "granted" if CGPreflightListenEventAccess() else "missing" + try: + from AVFoundation import AVCaptureDevice, AVMediaTypeAudio + + microphone = { + 0: "not-requested", + 1: "restricted", + 2: "denied", + 3: "granted", + }.get( + int(AVCaptureDevice.authorizationStatusForMediaType_(AVMediaTypeAudio)), + "unknown", + ) + except ImportError: + microphone = "unknown" + return { + "microphone": microphone, + "accessibility": accessibility, + "input": input_monitoring, + } From b3f30f89336f400d403bada820679d73aba24ff4 Mon Sep 17 00:00:00 2001 From: lucharo Date: Mon, 20 Jul 2026 11:20:34 +0100 Subject: [PATCH 12/38] fix: flatten SwiftBar permission controls --- swiftbar/v2t.5s.sh | 21 +++++++++++---------- tests/test_smoke.py | 46 ++++++++++++++++++++++++++++++++++++++++----- v2t/app.py | 19 ++++++++++++------- v2t/permissions.py | 24 +++++++++++++++++++++++ 4 files changed, 88 insertions(+), 22 deletions(-) diff --git a/swiftbar/v2t.5s.sh b/swiftbar/v2t.5s.sh index cc8b050..678c3ed 100755 --- a/swiftbar/v2t.5s.sh +++ b/swiftbar/v2t.5s.sh @@ -79,14 +79,14 @@ INPUT_PERMISSION="$(printf '%s' "$STATUS" | cut -f8)" permission_item() { NAME="$1"; STATE="$2"; PANE="$3" case "$STATE" in - granted) ICON="✓"; LABEL="Granted"; COLOR="#34c759" ;; - not-requested) ICON="○"; LABEL="Not requested"; COLOR="#8e8e93" ;; - denied) ICON="✕"; LABEL="Denied"; COLOR="#ff3b30" ;; - restricted) ICON="✕"; LABEL="Restricted"; COLOR="#ff3b30" ;; - missing) ICON="○"; LABEL="Not granted"; COLOR="#ff9500" ;; - *) ICON="?"; LABEL="Unknown"; COLOR="#8e8e93" ;; + granted) ICON="✓"; LABEL="Granted" ;; + not-requested) ICON="○"; LABEL="Start v2t to request" ;; + denied) ICON="✕"; LABEL="Denied" ;; + restricted) ICON="✕"; LABEL="Restricted" ;; + missing) ICON="○"; LABEL="Not granted" ;; + *) ICON="?"; LABEL="Unknown" ;; esac - echo "--$ICON $NAME · $LABEL | color=$COLOR bash=/usr/bin/open param0=\"$SEC?$PANE\" terminal=false" + echo "$ICON $NAME · $LABEL | bash=/usr/bin/open param0=\"$SEC?$PANE\" terminal=false" } case "$STATE" in @@ -118,7 +118,8 @@ echo "Permissions" permission_item "🎙️ Microphone" "$MIC_PERMISSION" "Privacy_Microphone" permission_item "♿ Accessibility" "$AX_PERMISSION" "Privacy_Accessibility" permission_item "⌨️ Input Monitoring" "$INPUT_PERMISSION" "Privacy_ListenEvent" -echo "Open config (~/.v2t) | bash=/usr/bin/open param0=\"$V2T_HOME\" terminal=false" -echo "Open transcription history | bash=/usr/bin/open param0=\"$V2T_HOME/history\" terminal=false" -[ -f "$V2T_HOME/run/v2t.log" ] && echo "Open log | bash=/usr/bin/open param0=\"$V2T_HOME/run/v2t.log\" terminal=false" +echo "---" +echo "Config | bash=/usr/bin/open param0=\"$V2T_HOME\" terminal=false" +echo "Transcription History | bash=/usr/bin/open param0=\"$V2T_HOME/history\" terminal=false" +[ -f "$V2T_HOME/run/v2t.log" ] && echo "Log | bash=/usr/bin/open param0=\"$V2T_HOME/run/v2t.log\" terminal=false" echo "Refresh | refresh=true" diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 02eb298..b54cdda 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -50,12 +50,16 @@ def test_audio_device_failure_returns_to_error_state(self): ) def test_permission_check_reports_each_missing_native_permission(self): - permissions = types.SimpleNamespace( - AXIsProcessTrusted=lambda: True, - CGPreflightListenEventAccess=lambda: False, - ) with ( - mock.patch.dict(sys.modules, {"ApplicationServices": permissions}), + mock.patch.object( + app.permissions, + "statuses", + return_value={ + "microphone": "granted", + "accessibility": "granted", + "input": "missing", + }, + ), mock.patch.object(app.subprocess, "run") as run, self.assertRaises(SystemExit), ): @@ -70,6 +74,25 @@ def test_permission_check_reports_each_missing_native_permission(self): ) self.assertIn("Input Monitoring", config.read_last_error()) + def test_permission_check_requests_microphone_before_startup(self): + with ( + mock.patch.object( + app.permissions, + "statuses", + return_value={ + "microphone": "not-requested", + "accessibility": "granted", + "input": "granted", + }, + ), + mock.patch.object( + app.permissions, "request_microphone", return_value=True + ) as request, + ): + app.check_and_request_permissions() + + request.assert_called_once_with() + def test_permission_statuses_use_native_macos_checks(self): application_services = types.SimpleNamespace( AXIsProcessTrusted=lambda: True, @@ -98,6 +121,19 @@ def test_permission_statuses_use_native_macos_checks(self): {"microphone": "granted", "accessibility": "granted", "input": "missing"}, ) + def test_microphone_request_waits_for_native_result(self): + device = types.SimpleNamespace( + authorizationStatusForMediaType_=lambda _media: 0, + requestAccessForMediaType_completionHandler_=lambda _media, + callback: callback(True), + ) + avfoundation = types.SimpleNamespace( + AVCaptureDevice=device, + AVMediaTypeAudio="audio", + ) + with mock.patch.dict(sys.modules, {"AVFoundation": avfoundation}): + self.assertTrue(permissions.request_microphone(timeout=0.1)) + def test_status_reports_the_running_overrides(self): cfg = config.Config(cleanup_enabled=False, mode="casual") voice = app.VoiceToText(cfg) diff --git a/v2t/app.py b/v2t/app.py index a5a38b4..342b9be 100644 --- a/v2t/app.py +++ b/v2t/app.py @@ -19,7 +19,7 @@ from loguru import logger from scipy.io import wavfile -from . import backends, config +from . import backends, config, permissions from .config import Config @@ -44,16 +44,21 @@ def _resolve_hotkey(name: str): def check_and_request_permissions() -> None: """Fail early with the exact macOS permission panes that still need a grant.""" - from ApplicationServices import AXIsProcessTrusted, CGPreflightListenEventAccess - logger.info("Checking permissions...") + states = permissions.statuses() + if states["microphone"] == "not-requested": + logger.info("Requesting Microphone permission...") + states["microphone"] = ( + "granted" if permissions.request_microphone() else "denied" + ) checks = [ - ("Accessibility", bool(AXIsProcessTrusted()), "Privacy_Accessibility"), + ("Microphone", states["microphone"] == "granted", "Privacy_Microphone"), ( - "Input Monitoring", - bool(CGPreflightListenEventAccess()), - "Privacy_ListenEvent", + "Accessibility", + states["accessibility"] == "granted", + "Privacy_Accessibility", ), + ("Input Monitoring", states["input"] == "granted", "Privacy_ListenEvent"), ] missing = [(name, pane) for name, granted, pane in checks if not granted] if missing: diff --git a/v2t/permissions.py b/v2t/permissions.py index 4452fba..e8d2aca 100644 --- a/v2t/permissions.py +++ b/v2t/permissions.py @@ -3,6 +3,7 @@ from __future__ import annotations import sys +import threading def statuses() -> dict[str, str]: @@ -32,3 +33,26 @@ def statuses() -> dict[str, str]: "accessibility": accessibility, "input": input_monitoring, } + + +def request_microphone(timeout: float = 60) -> bool: + """Ask macOS for microphone access once, waiting for the user's choice.""" + from AVFoundation import AVCaptureDevice, AVMediaTypeAudio + + current = int(AVCaptureDevice.authorizationStatusForMediaType_(AVMediaTypeAudio)) + if current != 0: + return current == 3 + + answered = threading.Event() + granted = False + + def complete(value): + nonlocal granted + granted = bool(value) + answered.set() + + AVCaptureDevice.requestAccessForMediaType_completionHandler_( + AVMediaTypeAudio, complete + ) + answered.wait(timeout) + return granted From b726e3194506ee5b64bde98c28cdeb8632c88750 Mon Sep 17 00:00:00 2001 From: lucharo Date: Mon, 20 Jul 2026 21:21:41 +0100 Subject: [PATCH 13/38] feat: replace SwiftBar with native menu --- .github/workflows/check.yml | 7 +- CHANGELOG.md | 8 +- README.md | 57 +- justfile | 2 +- pixi.lock | 1597 ----------------------------------- pyproject.toml | 19 - swiftbar/v2t.5s.sh | 125 --- tests/test_smoke.py | 119 ++- v2t/app.py | 23 +- v2t/cli.py | 51 +- v2t/config.py | 6 +- v2t/menubar.py | 135 +++ v2t/native/Voice2Text.swift | 292 +++++++ v2t/service.py | 58 +- 14 files changed, 616 insertions(+), 1883 deletions(-) delete mode 100644 pixi.lock delete mode 100755 swiftbar/v2t.5s.sh create mode 100644 v2t/menubar.py create mode 100644 v2t/native/Voice2Text.swift diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 7c4d597..7d5b0da 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -14,7 +14,6 @@ jobs: python-version: "3.12" - run: uv lock --check - run: uvx ruff check v2t tests - - run: bash -n swiftbar/v2t.5s.sh - name: Mechanics smoke tests run: >- uv run --no-project --python 3.12 @@ -22,3 +21,9 @@ jobs: python -m unittest -v tests.test_smoke - run: uv build - run: uvx twine check dist/* + + native: + runs-on: macos-latest + steps: + - uses: actions/checkout@v5 + - run: xcrun swiftc -typecheck -parse-as-library v2t/native/Voice2Text.swift -framework AppKit -framework AVFoundation -framework ApplicationServices diff --git a/CHANGELOG.md b/CHANGELOG.md index d0b3ea8..7d0ae7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,13 +11,13 @@ This release turns v2t into a small, modular, MLX-first package. - **Guided `v2t setup`** — pick the transcription model and cleanup engine; detects Ollama and offers it, otherwise defaults to mlx-lm. Writes `~/.v2t/config.toml`. - **Config file** at `~/.v2t/config.toml` (honors `$V2T_HOME` / `$XDG_CONFIG_HOME`), with `v2t config [--init]`. - **Transcription history** — every result + metadata appended to `~/.v2t/history/transcriptions.jsonl`. -- **SwiftBar plugin** (`swiftbar/v2t.5s.sh`) — menu-bar toggle, status, open config/history. -- **Optional LaunchAgent** — `v2t service install` keeps one warm process running across the login session; SwiftBar detects and controls it through the same status protocol. +- **Optional one-file menu-bar app** — native permission identity, immediate state, Start/Stop, and config/history/log links without a window or Xcode project. +- **Optional LaunchAgent** — `v2t service install` starts the same menu app at login and keeps one warm Python engine. - **Reliable runtime lifecycle** — single-instance locking, honest live states, microphone failure recovery, native permission checks, and safe shutdown while a transcription finishes. - **Private, lossless local data** — config/history/status use owner-only permissions, temporary audio is always removed, logs no longer include dictated text, and rich clipboard contents survive paste. -- **Reproducible install and release path** — constrained scientific dependencies, bundled SwiftBar installer, clean sdists, smoke-test CI, and validated build/release commands. +- **Reproducible install and release path** — constrained scientific dependencies, bundled Swift source, clean sdists, smoke-test CI, and validated build/release commands. - **Contributor benchmark harness** — `just bench` writes a per-machine markdown grid (STT RTF + cleanup TTFT/total) with `engine:model` columns. Optional Whisper stays in the dev dependency group. -- **New commands**: `v2t setup`, `v2t status`, `v2t stop`, `v2t service`, `v2t config`, `v2t swiftbar`. +- **New commands**: `v2t setup`, `v2t status`, `v2t stop`, `v2t service`, `v2t config`, `v2t menubar`. ## 0.2.0 diff --git a/README.md b/README.md index c74e838..3889767 100644 --- a/README.md +++ b/README.md @@ -21,12 +21,11 @@ enough now that the basics fit in a small Python package on consumer hardware. > **Heritage:** this started as a single `voice2text.py` under 300 lines — that proof-of-concept is > preserved forever at the [`nano`](https://github.com/lucharo/voice2text/releases/tag/nano) tag. > From `0.3.0` it's a small, modular package: pluggable MLX backends, one config file, a benchmark -> harness, and a SwiftBar menu-bar toggle. +> harness, and an optional one-file menu-bar app. > **Design tenet — be communicative.** An ergonomic tool is an expressive one: every action gets -> immediate, visible feedback. The menu-bar icon tracks live state (off · loading · ready · -> recording · transcribing · cleaning · error) on SwiftBar's five-second refresh, so you always -> know whether the tool heard you. +> immediate, visible feedback. The optional menu-bar icon tracks live state (off · loading · ready · +> recording · transcribing · cleaning · error), so you always know whether the tool heard you. ## What you get @@ -35,7 +34,7 @@ enough now that the basics fit in a small Python package on consumer hardware. - **In-process LLM cleanup** via mlx-lm (`Qwen3-4B-Instruct-2507`) — fixes punctuation, removes fillers. No Ollama, no daemon. Strict or casual. (Ollama optional — see [Cleanup engine](#cleanup-engine).) - **Pastes at cursor**, restoring your previous clipboard. - **One config file** at `~/.v2t/config.toml`, plus a JSONL **history** of every transcription. -- **SwiftBar plugin** — menu-bar toggle, status, and quick links to your config/history. +- **Optional one-file Swift menu** — native permissions, instant state, and quick links; no window or Xcode project. ## Install @@ -56,7 +55,7 @@ uv tool install 'voice2text[whisper]' # adds the Whisper backend; select it in ```
-Other install methods (uvx, pip, dev, pixi) +Other install methods (uvx, pip, dev) ```bash # quick try (fresh venv each run — slower startup) @@ -68,9 +67,6 @@ pip install voice2text && v2t # from source git clone https://github.com/lucharo/voice2text.git && cd voice2text uv sync --no-dev && uv run v2t - -# pixi -pixi run v2t ```
@@ -84,10 +80,11 @@ v2t --backend whisper # use the whisper backend for this run v2t --pause-music # pause media while recording (needs nowplaying-cli) v2t setup # guided config: pick models, detect Ollama -v2t status # running / idle (used by the SwiftBar plugin) +v2t status # running / idle (also used by the menu app) v2t stop # stop a running v2t v2t config # show resolved config + paths (--init writes a template) -v2t service install # optional: keep v2t warm and start it at login +v2t menubar install # optional: compile + open the tiny native menu app +v2t service install # optional: start that menu app at login ``` Hold **Right Command** to record, release to transcribe and paste. @@ -109,7 +106,7 @@ Everything lives in one directory (override with `$V2T_HOME`, or `$XDG_CONFIG_HO ~/.v2t/ config.toml # all settings (v2t config --init to create) history/transcriptions.jsonl # every transcription + metadata (toggle in config) - run/ # pid + status for the SwiftBar plugin + run/ # private runtime status + log ``` `config.toml` (every key optional — these are the defaults): @@ -150,37 +147,33 @@ model = "qwen3:4b-instruct-2507" # then: ollama pull qwen3:4b-instruct-2507 Either way, use a **non-thinking** model — a model that emits `` blocks will paste its reasoning. The defaults don't. -## SwiftBar menu-bar toggle +## Optional menu-bar app ```bash -brew install swiftbar -v2t swiftbar # install/update the bundled, hackable shell plugin +v2t menubar install # compile the bundled Swift file into ~/Applications/Voice2Text.app ``` -The menu shows a colored live-state icon (off · loading · ready · recording · transcribing · -cleaning · error), the active models, a Start/Stop toggle, a **Permissions** submenu, and links to -open your config, transcription history, and log. The installed plugin remains a normal shell file -in SwiftBar's Plugins folder, so it is easy to inspect or change. +The optional app is a single, inspectable Swift source file — no window, Xcode project, AppleScript, +or separate settings system. It exists because macOS only grants microphone access to a real app +identity. The menu requests the three native grants, starts one long-running Python process, shows +state immediately, and links to config, history, and log. Run `v2t` in a terminal instead if you do +not want the menu. -SwiftBar's normal **Start v2t** action already launches one long-running process: Parakeet and the -cleanup model load once, then stay warm for every transcription. To also start that process at login, -install the optional per-user LaunchAgent: +**Start v2t** loads Parakeet and the cleanup model once, then keeps them warm for every +transcription. To start the same menu app at login, install the optional per-user LaunchAgent: ```bash v2t service install # install + start ~/Library/LaunchAgents/com.lucharo.voice2text.plist v2t service status -v2t service uninstall # stop it and return SwiftBar to direct-launch mode +v2t service uninstall ``` -The service uses the same lock and status files as direct mode, so SwiftBar detects it automatically. -It runs the exact Python executable shown during installation. If macOS lists that executable as a -separate app in Privacy & Security, grant it the same three permissions. +The service starts the same `Voice2Text.app` bundle, so manual and login launches share one stable +permission identity. The engine lock still prevents duplicate Python processes. -**Permissions.** v2t needs three grants, given to the app that *launches* it (your terminal, or -SwiftBar if you use "Start v2t"): **Microphone** (record), **Accessibility** + **Input Monitoring** -(hotkey + paste). The Permissions submenu shows the native state of each grant and opens its pane — -grant missing permissions, then **restart the launching app** (macOS only applies the grant on -relaunch). If audio comes back silent, v2t says so and opens the Mic pane. +**Permissions.** v2t needs **Microphone** (record), **Accessibility** + **Input Monitoring** (hotkey ++ paste). A terminal launch uses your terminal's grants. The menu app requests its own grants and +shows their live state as flat rows; click a missing row to open the exact Settings pane. ## Models & benchmarks @@ -193,5 +186,5 @@ not the installed CLI. See [`benchmarks/`](benchmarks/) for the method and defau | transcription | `parakeet-tdt-0.6b-v3` | fastest on Apple Silicon, multilingual | | cleanup | `Qwen3-4B-Instruct-2507` (mlx-lm) | latest small instruct, non-thinking, no daemon | -> This is **macOS / Apple Silicon-only** by design (MLX, `osascript` paste, the native pasteboard, +> This is **macOS / Apple Silicon-only** by design (MLX, native pasteboard/event APIs, > `nowplaying-cli`, System Settings permission URLs). Fork it for Linux/Windows if you like. diff --git a/justfile b/justfile index 05938d0..c363b12 100644 --- a/justfile +++ b/justfile @@ -7,7 +7,7 @@ check: lint uv run python -m unittest -v tests.test_smoke uv run python -m v2t.config uv run python -m v2t.backends - bash -n swiftbar/v2t.5s.sh + xcrun swiftc -typecheck -parse-as-library v2t/native/Voice2Text.swift -framework AppKit -framework AVFoundation -framework ApplicationServices # Contributor benchmark harness (the dev group includes optional Whisper) bench *args: diff --git a/pixi.lock b/pixi.lock deleted file mode 100644 index a920827..0000000 --- a/pixi.lock +++ /dev/null @@ -1,1597 +0,0 @@ -version: 7 -platforms: -- name: osx-arm64 - virtual-packages: - - __unix=0=0 - - __osx=13.0 - - __archspec=0=m1 -environments: - default: - channels: - - url: https://conda.anaconda.org/conda-forge/ - indexes: - - https://pypi.org/simple - packages: - osx-arm64: - - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.11.12-hbd8a1cb_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h78e105d_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-hd037594_8.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-21.1.7-hf598326_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.7.3-haf25636_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-he5f378a_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.1-h39f12f2_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h5505292_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.51.1-h9a5124b_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.1-h8359307_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.5-h5e97a16_3.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ollama-0.13.2-cpu_h19ea9b3_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.0-h5503f6c_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.13.11-hfc2f54d_100_cp313.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.2-h1d1bf99_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h892fb3f_3.conda - - pypi: ./ - - pypi: https://files.pythonhosted.org/packages/03/dc/200013a74641f8774664bbcd2346c695c05c2e300ea792adcb40a293eed0/soxr-1.1.0-cp312-abi3-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/0a/4b/861f91a1565d3189ee899e177b915551fb9a7e2ca25414025a8974f04e74/pyobjc_framework_quartz-12.2.1-cp313-cp313-macosx_10_13_universal2.whl - - pypi: https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl - - pypi: https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/1b/83/ea581e364ce7b0d41456fb79d6ee0ad482beda61faf0cab20cbd4c63a541/audioop_lts-0.2.2-cp313-abi3-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/20/a2/a3974e3e807c68e23a9d7db66fc38ac54f7ecd2b7a9237042006699a76e1/pyobjc_framework_coretext-12.1-cp313-cp313-macosx_10_13_universal2.whl - - pypi: https://files.pythonhosted.org/packages/2a/2d/d4bf65e47cea8ff2c794a600c4fd1273a7902f268757c531e0ee9f18aa58/pooch-1.9.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/2f/39/714118f8413e0e353436914f2b976665161f1be2b6483ac15a8f61484c14/sounddevice-0.5.3-py3-none-macosx_10_6_x86_64.macosx_10_6_universal2.whl - - pypi: https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/34/ae/e3707f6c1bc6f7aa0df600ba8075bfb8a19252140cd595335be60e25f9ee/standard_sunau-3.13.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/34/db/f9ea1a6844b4fa5dfe2312095cd866a1f724cd0905054ab9d5991778ba50/sentencepiece-0.2.2-cp313-cp313-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/39/e4/71b2e3bd03f0404c89b432273d272dc5427185fd9ed828036730bfc9d057/pyobjc_framework_coreaudio-12.2.1-cp313-cp313-macosx_10_13_universal2.whl - - pypi: https://files.pythonhosted.org/packages/3e/04/5acd7ae280c5f93b6ac5ef6cdec14eef4c8d1cd91d85b3292989c94d96b1/scikit_learn-1.9.0-cp313-cp313-macosx_12_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/3e/1e/b9b0ddffae66996b8779f1f7958adc9f21c13a0448cd3be8d7fe589b5b0f/pyobjc_core-12.2.1-cp313-cp313-macosx_10_13_universal2.whl - - pypi: https://files.pythonhosted.org/packages/40/03/26a383c9e58c213199d1aad1c3d353cfc22d4444ec6d2c0bf8ad02523843/typer-0.27.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/41/95/a00054a006df82bb1b5b8f666ae44a676b259146fadbff90fe654309fefc/mlx_metal-0.29.3-py3-none-macosx_13_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/44/13/7e63cfba8a7452eb756306aa2fd9b37a29a323b672b964b4fdeded9a3f21/scipy-1.16.3-cp313-cp313-macosx_12_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/51/c7/b64cae5dba3a1b138d7123ec36bb5ccd39d39939f18454407e5468f4763f/fsspec-2025.12.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/57/f7/e19e6eff445bec52dde5bed1ebb162925a8e6f988164f1ae4b3475a73680/numba-0.63.1-cp313-cp313-macosx_12_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/59/4f/ac3fa906ae8a375a536b12794128c5efacade9eaa917a35dfd27ce0c7400/pynput-1.8.1-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/5f/c3/aeaaf3911d2529614be18d1c8b5496afc185560e76568063d517283318af/huggingface_hub-1.24.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/60/ac/b097a86a1e4a6098f3a79382643ab09d5733d87ccc864877ad1e12b49b70/hf_xet-1.5.2-cp38-abi3-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/63/7a/dfdd6f8c748988427119f75eb860a3cedd858d1aea1fe28f39ad8559ef22/soundfile-0.14.0-py2.py3-none-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/64/71/fbcfa83a1d6a9c6091942d1cfd070962244664b87427a9a49a6897b1b219/msgpack-1.2.1-cp313-cp313-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/6f/67/8d85ca2323233ae3c0365a659c4e52ee1f587b440e4bc577e7d8e4416d0f/transformers-5.14.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/79/fb/f505c95ceddd7027347b067689db71ca80bd5ecc926f913f1a23e65cf09b/numpy-2.3.5-cp313-cp313-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/7a/90/a5c1084d87767d787a6caba615aa50dc587229646308d9420c960cb5e4c0/standard_chunk-3.13.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/7e/16/fbe8e1e185a45042f7cd3a282def5bb8d95bb69ab9e9ef6a5368aa17e426/audioread-3.1.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/7e/85/a5bfaebfd305ac18b57b0854d74e37e586809061a91fda62f0bd50c8518e/narwhals-2.24.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/8a/a1/8d812e53a5da1687abb10445275d41a8b13adb781bbf7196ddbcf8d88505/lazy_loader-0.5-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/94/35/386550fd60316d1e37eccdda609b074113298f23cef5bddb2049823fe666/dacite-1.9.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl - - pypi: https://files.pythonhosted.org/packages/98/f5/b38da31d9f95770d0f0f9b9724a898d240d6fb630796e04e9682384d086c/pyobjc_framework_avfoundation-12.2.1-cp313-cp313-macosx_10_13_universal2.whl - - pypi: https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/a3/88/1a3ea5672f4b0a84802ee9891b86743438e7c04eb0b8f8c4e16a42375327/regex-2025.11.3-cp313-cp313-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/b5/ba/c63c5786dfee4c3417094c4b00966e61e4a63efecee22cb7b4c0387dda83/librosa-0.11.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c3/52/5fbb203394cc852334d1575cc020f6bcec768d2265355984dfd361968f36/standard_aifc-3.13.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/cc/46/68e8e4d926a2f70fed0437047bc3f9fe08af8fe620d94d80656ebc3cfa9b/pyobjc_framework_cocoa-12.2.1-cp313-cp313-macosx_10_13_universal2.whl - - pypi: https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/d6/2a/9e5beae2961c9d22ce16258f39edae69996ee0167dd4cfe4e771454086c1/pyobjc_framework_coremedia-12.2.1-cp313-cp313-macosx_10_13_universal2.whl - - pypi: https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/e5/53/913099c91d384e115ea078325efd9a0bc1ea3eb3458c694b4596cbd267f2/mlx_lm-0.29.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/e6/ff/3eba7eb0aed4b6fca37125387cd417e8c458e750621fce56d2c541f67fa8/llvmlite-0.46.0-cp313-cp313-macosx_12_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/ec/73/6fd0bb9ce84138c3857f12e9de63bc901852975a092d545f18087a204aa2/platformdirs-4.10.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/f4/54/09a3501c74f1ef54a36002534661cb89a0b2f098fcad87fe64f515a0dba7/parakeet_mlx-0.5.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/f5/b1/fa7c600e7dceae12e9606c7578cbc9ff1e1ed55844883ee5c92205e86226/safetensors-0.8.0-cp310-abi3-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/fc/21/79e42ee836f1010f5fe9e97d2817a006736bd287c15a3674c399190a2e77/pyobjc_framework_applicationservices-12.1-cp313-cp313-macosx_10_13_universal2.whl - - pypi: https://files.pythonhosted.org/packages/fe/a2/078152b45aa8a23949a1b09601d0044f8bb4ab85e909e4475a440c21aaea/mlx-0.29.3-cp313-cp313-macosx_13_0_arm64.whl -packages: -- conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.11.12-hbd8a1cb_0.conda - sha256: b986ba796d42c9d3265602bc038f6f5264095702dd546c14bc684e60c385e773 - md5: f0991f0f84902f6b6009b4d2350a83aa - depends: - - __unix - license: ISC - purls: [] - size: 152432 - timestamp: 1762967197890 -- conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda - build_number: 8 - sha256: 210bffe7b121e651419cb196a2a63687b087497595c9be9d20ebe97dd06060a7 - md5: 94305520c52a4aa3f6c2b1ff6008d9f8 - constrains: - - python 3.13.* *_cp313 - license: BSD-3-Clause - license_family: BSD - purls: [] - size: 7002 - timestamp: 1752805902938 -- conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h78e105d_0.conda - sha256: 5aaa366385d716557e365f0a4e9c3fca43ba196872abbbe3d56bb610d131e192 - md5: 4222072737ccff51314b5ece9c7d6f5a - license: LicenseRef-Public-Domain - purls: [] - size: 122968 - timestamp: 1742727099393 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-hd037594_8.conda - sha256: b456200636bd5fecb2bec63f7e0985ad2097cf1b83d60ce0b6968dffa6d02aa1 - md5: 58fd217444c2a5701a44244faf518206 - depends: - - __osx >=11.0 - license: bzip2-1.0.6 - license_family: BSD - purls: [] - size: 125061 - timestamp: 1757437486465 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-21.1.7-hf598326_0.conda - sha256: 4bdbef0241b52e7a8552e8af7425f0b56d5621dd69df46c816546fefa17d77ab - md5: 0de94f39727c31c0447e408c5a210a56 - depends: - - __osx >=11.0 - license: Apache-2.0 WITH LLVM-exception - license_family: Apache - purls: [] - size: 568715 - timestamp: 1764676451068 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.7.3-haf25636_0.conda - sha256: fce22610ecc95e6d149e42a42fbc3cc9d9179bd4eb6232639a60f06e080eec98 - md5: b79875dbb5b1db9a4a22a4520f918e1a - depends: - - __osx >=11.0 - constrains: - - expat 2.7.3.* - license: MIT - license_family: MIT - purls: [] - size: 67800 - timestamp: 1763549994166 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-he5f378a_0.conda - sha256: 9b8acdf42df61b7bfe8bdc545c016c29e61985e79748c64ad66df47dbc2e295f - md5: 411ff7cd5d1472bba0f55c0faf04453b - depends: - - __osx >=11.0 - license: MIT - license_family: MIT - purls: [] - size: 40251 - timestamp: 1760295839166 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.1-h39f12f2_2.conda - sha256: 0cb92a9e026e7bd4842f410a5c5c665c89b2eb97794ffddba519a626b8ce7285 - md5: d6df911d4564d77c4374b02552cb17d1 - depends: - - __osx >=11.0 - constrains: - - xz 5.8.1.* - license: 0BSD - purls: [] - size: 92286 - timestamp: 1749230283517 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h5505292_0.conda - sha256: 0a1875fc1642324ebd6c4ac864604f3f18f57fbcf558a8264f6ced028a3c75b2 - md5: 85ccccb47823dd9f7a99d2c7f530342f - depends: - - __osx >=11.0 - license: BSD-2-Clause - license_family: BSD - purls: [] - size: 71829 - timestamp: 1748393749336 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.51.1-h9a5124b_0.conda - sha256: a46b167447e2a9e38586320c30b29e3b68b6f7e6b873c18d6b1aa2efd2626917 - md5: 67e50e5bd4e5e2310d66b88c4da50096 - depends: - - __osx >=11.0 - - libzlib >=1.3.1,<2.0a0 - license: blessing - purls: [] - size: 906292 - timestamp: 1764359907797 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.1-h8359307_2.conda - sha256: ce34669eadaba351cd54910743e6a2261b67009624dbc7daeeafdef93616711b - md5: 369964e85dc26bfe78f41399b366c435 - depends: - - __osx >=11.0 - constrains: - - zlib 1.3.1 *_2 - license: Zlib - license_family: Other - purls: [] - size: 46438 - timestamp: 1727963202283 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.5-h5e97a16_3.conda - sha256: 2827ada40e8d9ca69a153a45f7fd14f32b2ead7045d3bbb5d10964898fe65733 - md5: 068d497125e4bf8a66bf707254fff5ae - depends: - - __osx >=11.0 - license: X11 AND BSD-3-Clause - purls: [] - size: 797030 - timestamp: 1738196177597 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/ollama-0.13.2-cpu_h19ea9b3_0.conda - sha256: 2d71d0c04a1a6a856e14147373cccc99beaa4fcc7d18f2afb9548fe1103f772b - md5: 70dc3952b80d824be4f86057e37f660b - depends: - - __osx >=11.0 - - libcxx >=19 - license: MIT - purls: [] - size: 9599569 - timestamp: 1765226601139 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.0-h5503f6c_0.conda - sha256: ebe93dafcc09e099782fe3907485d4e1671296bc14f8c383cb6f3dfebb773988 - md5: b34dc4172653c13dcf453862f251af2b - depends: - - __osx >=11.0 - - ca-certificates - license: Apache-2.0 - license_family: Apache - purls: [] - size: 3108371 - timestamp: 1762839712322 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.13.11-hfc2f54d_100_cp313.conda - build_number: 100 - sha256: c476f4e9b6d97c46b496b442878924868a54e5727251549ebfc82027aa52af68 - md5: 18a8c69608151098a8fb75eea64cc266 - depends: - - __osx >=11.0 - - bzip2 >=1.0.8,<2.0a0 - - libexpat >=2.7.3,<3.0a0 - - libffi >=3.5.2,<3.6.0a0 - - liblzma >=5.8.1,<6.0a0 - - libmpdec >=4.0.0,<5.0a0 - - libsqlite >=3.51.1,<4.0a0 - - libzlib >=1.3.1,<2.0a0 - - ncurses >=6.5,<7.0a0 - - openssl >=3.5.4,<4.0a0 - - python_abi 3.13.* *_cp313 - - readline >=8.2,<9.0a0 - - tk >=8.6.13,<8.7.0a0 - - tzdata - license: Python-2.0 - purls: [] - size: 12920650 - timestamp: 1765020887340 - python_site_packages_path: lib/python3.13/site-packages -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.2-h1d1bf99_2.conda - sha256: 7db04684d3904f6151eff8673270922d31da1eea7fa73254d01c437f49702e34 - md5: 63ef3f6e6d6d5c589e64f11263dc5676 - depends: - - ncurses >=6.5,<7.0a0 - license: GPL-3.0-only - license_family: GPL - purls: [] - size: 252359 - timestamp: 1740379663071 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h892fb3f_3.conda - sha256: ad0c67cb03c163a109820dc9ecf77faf6ec7150e942d1e8bb13e5d39dc058ab7 - md5: a73d54a5abba6543cb2f0af1bfbd6851 - depends: - - __osx >=11.0 - - libzlib >=1.3.1,<2.0a0 - license: TCL - license_family: BSD - purls: [] - size: 3125484 - timestamp: 1763055028377 -- pypi: ./ - name: voice2text - requires_dist: - - sounddevice - - numpy<2.4 - - scipy - - pynput - - pyobjc-framework-avfoundation ; sys_platform == 'darwin' - - loguru - - parakeet-mlx - - mlx-lm - - mlx-whisper ; extra == 'whisper' - requires_python: '>=3.11' -- pypi: https://files.pythonhosted.org/packages/03/dc/200013a74641f8774664bbcd2346c695c05c2e300ea792adcb40a293eed0/soxr-1.1.0-cp312-abi3-macosx_11_0_arm64.whl - name: soxr - version: 1.1.0 - sha256: d6a7ad82b8d5f3fcc04b1d2ca055562b96af571e1d4fa7c6c61d0fb509ac43b4 - requires_dist: - - numpy - - sphinx ; extra == 'docs' - - sphinx-book-theme ; extra == 'docs' - - myst-parser ; extra == 'docs' - - linkify-it-py ; extra == 'docs' - - pytest ; extra == 'test' - requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl - name: h11 - version: 0.16.0 - sha256: 63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 - requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl - name: decorator - version: 5.3.1 - sha256: f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c - requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/0a/4b/861f91a1565d3189ee899e177b915551fb9a7e2ca25414025a8974f04e74/pyobjc_framework_quartz-12.2.1-cp313-cp313-macosx_10_13_universal2.whl - name: pyobjc-framework-quartz - version: 12.2.1 - sha256: 54c9bc7f507192691841ee4eba5bf36990b259df83ac728efed2d7ea1cd021e4 - requires_dist: - - pyobjc-core>=12.2.1 - - pyobjc-framework-cocoa>=12.2.1 - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl - name: loguru - version: 0.7.3 - sha256: 31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c - requires_dist: - - colorama>=0.3.4 ; sys_platform == 'win32' - - aiocontextvars>=0.2.0 ; python_full_version < '3.7' - - win32-setctime>=1.0.0 ; sys_platform == 'win32' - - pre-commit==4.0.1 ; python_full_version >= '3.9' and extra == 'dev' - - tox==3.27.1 ; python_full_version < '3.8' and extra == 'dev' - - tox==4.23.2 ; python_full_version >= '3.8' and extra == 'dev' - - pytest==6.1.2 ; python_full_version < '3.8' and extra == 'dev' - - pytest==8.3.2 ; python_full_version >= '3.8' and extra == 'dev' - - pytest-cov==2.12.1 ; python_full_version < '3.8' and extra == 'dev' - - pytest-cov==5.0.0 ; python_full_version == '3.8.*' and extra == 'dev' - - pytest-cov==6.0.0 ; python_full_version >= '3.9' and extra == 'dev' - - pytest-mypy-plugins==1.9.3 ; python_full_version >= '3.6' and python_full_version < '3.8' and extra == 'dev' - - pytest-mypy-plugins==3.1.0 ; python_full_version >= '3.8' and extra == 'dev' - - colorama==0.4.5 ; python_full_version < '3.8' and extra == 'dev' - - colorama==0.4.6 ; python_full_version >= '3.8' and extra == 'dev' - - freezegun==1.1.0 ; python_full_version < '3.8' and extra == 'dev' - - freezegun==1.5.0 ; python_full_version >= '3.8' and extra == 'dev' - - exceptiongroup==1.1.3 ; python_full_version >= '3.7' and python_full_version < '3.11' and extra == 'dev' - - mypy==0.910 ; python_full_version < '3.6' and extra == 'dev' - - mypy==0.971 ; python_full_version == '3.6.*' and extra == 'dev' - - mypy==1.4.1 ; python_full_version == '3.7.*' and extra == 'dev' - - mypy==1.13.0 ; python_full_version >= '3.8' and extra == 'dev' - - sphinx==8.1.3 ; python_full_version >= '3.11' and extra == 'dev' - - sphinx-rtd-theme==3.0.2 ; python_full_version >= '3.11' and extra == 'dev' - - myst-parser==4.0.0 ; python_full_version >= '3.11' and extra == 'dev' - - build==1.2.2 ; python_full_version >= '3.11' and extra == 'dev' - - twine==6.0.1 ; python_full_version >= '3.11' and extra == 'dev' - requires_python: '>=3.5,<4.0' -- pypi: https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl - name: idna - version: '3.11' - sha256: 771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea - requires_dist: - - ruff>=0.6.2 ; extra == 'all' - - mypy>=1.11.2 ; extra == 'all' - - pytest>=8.3.2 ; extra == 'all' - - flake8>=7.1.1 ; extra == 'all' - requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl - name: protobuf - version: 7.35.1 - sha256: 24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6 - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl - name: typing-extensions - version: 4.15.0 - sha256: f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548 - requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/1b/83/ea581e364ce7b0d41456fb79d6ee0ad482beda61faf0cab20cbd4c63a541/audioop_lts-0.2.2-cp313-abi3-macosx_11_0_arm64.whl - name: audioop-lts - version: 0.2.2 - sha256: 9a13dc409f2564de15dd68be65b462ba0dde01b19663720c68c1140c782d1d75 - requires_python: '>=3.13' -- pypi: https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl - name: annotated-doc - version: 0.0.4 - sha256: 571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320 - requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl - name: requests - version: 2.32.5 - sha256: 2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6 - requires_dist: - - charset-normalizer>=2,<4 - - idna>=2.5,<4 - - urllib3>=1.21.1,<3 - - certifi>=2017.4.17 - - pysocks>=1.5.6,!=1.5.7 ; extra == 'socks' - - chardet>=3.0.2,<6 ; extra == 'use-chardet-on-py3' - requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl - name: packaging - version: '25.0' - sha256: 29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 - requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/20/a2/a3974e3e807c68e23a9d7db66fc38ac54f7ecd2b7a9237042006699a76e1/pyobjc_framework_coretext-12.1-cp313-cp313-macosx_10_13_universal2.whl - name: pyobjc-framework-coretext - version: '12.1' - sha256: 7cbb2c28580e6704ce10b9a991ccd9563a22b3a75f67c36cf612544bd8b21b5f - requires_dist: - - pyobjc-core>=12.1 - - pyobjc-framework-cocoa>=12.1 - - pyobjc-framework-quartz>=12.1 - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/2a/2d/d4bf65e47cea8ff2c794a600c4fd1273a7902f268757c531e0ee9f18aa58/pooch-1.9.0-py3-none-any.whl - name: pooch - version: 1.9.0 - sha256: f265597baa9f760d25ceb29d0beb8186c243d6607b0f60b83ecf14078dbc703b - requires_dist: - - platformdirs>=2.5.0 - - packaging>=20.0 - - requests>=2.19.0 - - tqdm>=4.41.0,<5.0.0 ; extra == 'progress' - - paramiko>=2.7.0 ; extra == 'sftp' - - xxhash>=1.4.3 ; extra == 'xxhash' - - pytest-httpserver ; extra == 'test' - - pytest-localftpserver ; extra == 'test' - requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl - name: httpx - version: 0.28.1 - sha256: d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad - requires_dist: - - anyio - - certifi - - httpcore==1.* - - idna - - brotli ; platform_python_implementation == 'CPython' and extra == 'brotli' - - brotlicffi ; platform_python_implementation != 'CPython' and extra == 'brotli' - - click==8.* ; extra == 'cli' - - pygments==2.* ; extra == 'cli' - - rich>=10,<14 ; extra == 'cli' - - h2>=3,<5 ; extra == 'http2' - - socksio==1.* ; extra == 'socks' - - zstandard>=0.18.0 ; extra == 'zstd' - requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl - name: tokenizers - version: 0.22.2 - sha256: 1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001 - requires_dist: - - huggingface-hub>=0.16.4,<2.0 - - pytest ; extra == 'testing' - - pytest-asyncio ; extra == 'testing' - - requests ; extra == 'testing' - - numpy ; extra == 'testing' - - datasets ; extra == 'testing' - - ruff ; extra == 'testing' - - ty ; extra == 'testing' - - sphinx ; extra == 'docs' - - sphinx-rtd-theme ; extra == 'docs' - - setuptools-rust ; extra == 'docs' - - tokenizers[testing] ; extra == 'dev' - requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/2f/39/714118f8413e0e353436914f2b976665161f1be2b6483ac15a8f61484c14/sounddevice-0.5.3-py3-none-macosx_10_6_x86_64.macosx_10_6_universal2.whl - name: sounddevice - version: 0.5.3 - sha256: 278dc4451fff70934a176df048b77d80d7ce1623a6ec9db8b34b806f3112f9c2 - requires_dist: - - cffi>=1.0 - - numpy ; extra == 'numpy' - requires_python: '>=3.7' -- pypi: https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl - name: threadpoolctl - version: 3.6.0 - sha256: 43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb - requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/34/ae/e3707f6c1bc6f7aa0df600ba8075bfb8a19252140cd595335be60e25f9ee/standard_sunau-3.13.0-py3-none-any.whl - name: standard-sunau - version: 3.13.0 - sha256: 53af624a9529c41062f4c2fd33837f297f3baa196b0cfceffea6555654602622 - requires_dist: - - audioop-lts ; python_full_version >= '3.13' -- pypi: https://files.pythonhosted.org/packages/34/db/f9ea1a6844b4fa5dfe2312095cd866a1f724cd0905054ab9d5991778ba50/sentencepiece-0.2.2-cp313-cp313-macosx_11_0_arm64.whl - name: sentencepiece - version: 0.2.2 - sha256: 201a8e0f55501a76e08dbf2c54bc45f4642b379271e89c667d517bfbc2191f2a - requires_dist: - - numpy ; extra == 'numpy' - - protobuf ; extra == 'protobuf' - - pytest ; extra == 'test' - - numpy ; extra == 'test' - - protobuf ; extra == 'test' - requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/39/e4/71b2e3bd03f0404c89b432273d272dc5427185fd9ed828036730bfc9d057/pyobjc_framework_coreaudio-12.2.1-cp313-cp313-macosx_10_13_universal2.whl - name: pyobjc-framework-coreaudio - version: 12.2.1 - sha256: 6fb46bc090edcaefeb70be2c179071e7a758e3375aebd3338b11773e371c3dce - requires_dist: - - pyobjc-core>=12.2.1 - - pyobjc-framework-cocoa>=12.2.1 - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/3e/04/5acd7ae280c5f93b6ac5ef6cdec14eef4c8d1cd91d85b3292989c94d96b1/scikit_learn-1.9.0-cp313-cp313-macosx_12_0_arm64.whl - name: scikit-learn - version: 1.9.0 - sha256: 5b934c45c252844a91d69fda3a34cff5e7307e1db10d77cb10a3980312c74713 - requires_dist: - - numpy>=1.24.1 - - scipy>=1.10.0 - - joblib>=1.4.0 - - narwhals>=2.0.1 - - threadpoolctl>=3.5.0 - - numpy>=1.24.1 ; extra == 'build' - - scipy>=1.10.0 ; extra == 'build' - - cython>=3.1.2 ; extra == 'build' - - meson-python>=0.17.1 ; extra == 'build' - - numpy>=1.24.1 ; extra == 'install' - - scipy>=1.10.0 ; extra == 'install' - - joblib>=1.4.0 ; extra == 'install' - - narwhals>=2.0.1 ; extra == 'install' - - threadpoolctl>=3.5.0 ; extra == 'install' - - matplotlib>=3.6.1 ; extra == 'benchmark' - - pandas>=1.5.0 ; extra == 'benchmark' - - memory-profiler>=0.57.0 ; extra == 'benchmark' - - matplotlib>=3.6.1 ; extra == 'docs' - - scikit-image>=0.22.0 ; extra == 'docs' - - pandas>=1.5.0 ; extra == 'docs' - - rich>=14.1.0 ; extra == 'docs' - - seaborn>=0.13.0 ; extra == 'docs' - - memory-profiler>=0.57.0 ; extra == 'docs' - - sphinx>=7.3.7 ; extra == 'docs' - - sphinx-copybutton>=0.5.2 ; extra == 'docs' - - sphinx-gallery>=0.17.1 ; extra == 'docs' - - numpydoc>=1.2.0 ; extra == 'docs' - - pillow>=12.1.1 ; extra == 'docs' - - pooch>=1.8.0 ; extra == 'docs' - - sphinx-prompt>=1.4.0 ; extra == 'docs' - - sphinxext-opengraph>=0.9.1 ; extra == 'docs' - - plotly>=5.22.0 ; extra == 'docs' - - polars>=0.20.30 ; extra == 'docs' - - sphinx-design>=0.6.0 ; extra == 'docs' - - sphinxcontrib-sass>=0.3.4 ; extra == 'docs' - - pydata-sphinx-theme>=0.15.3 ; extra == 'docs' - - sphinx-remove-toctrees>=1.0.0.post1 ; extra == 'docs' - - towncrier>=24.8.0 ; extra == 'docs' - - matplotlib>=3.6.1 ; extra == 'examples' - - scikit-image>=0.22.0 ; extra == 'examples' - - pandas>=1.5.0 ; extra == 'examples' - - rich>=14.1.0 ; extra == 'examples' - - seaborn>=0.13.0 ; extra == 'examples' - - pooch>=1.8.0 ; extra == 'examples' - - plotly>=5.22.0 ; extra == 'examples' - - matplotlib>=3.6.1 ; extra == 'tests' - - pandas>=1.5.0 ; extra == 'tests' - - rich>=14.1.0 ; extra == 'tests' - - pytest>=7.1.2 ; extra == 'tests' - - pytest-cov>=2.9.0 ; extra == 'tests' - - ruff>=0.12.2 ; extra == 'tests' - - mypy>=1.15 ; extra == 'tests' - - pyamg>=5.0.0 ; extra == 'tests' - - polars>=0.20.30 ; extra == 'tests' - - pyarrow>=13.0.0 ; extra == 'tests' - - numpydoc>=1.2.0 ; extra == 'tests' - - pooch>=1.8.0 ; extra == 'tests' - - conda-lock==3.0.1 ; extra == 'maintenance' - requires_python: '>=3.11' -- pypi: https://files.pythonhosted.org/packages/3e/1e/b9b0ddffae66996b8779f1f7958adc9f21c13a0448cd3be8d7fe589b5b0f/pyobjc_core-12.2.1-cp313-cp313-macosx_10_13_universal2.whl - name: pyobjc-core - version: 12.2.1 - sha256: af101222762665a4125157906cb4b23f5d5a63d3851d5e0504f72a1eaaa2cfd2 - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/40/03/26a383c9e58c213199d1aad1c3d353cfc22d4444ec6d2c0bf8ad02523843/typer-0.27.0-py3-none-any.whl - name: typer - version: 0.27.0 - sha256: 6f4b27631e47f077871b7dc30e933ec0131c1390fbe0e387ea5574b5bac9ccf1 - requires_dist: - - shellingham>=1.3.0 - - rich>=13.8.0 - - annotated-doc>=0.0.2 - - colorama ; sys_platform == 'win32' - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/41/95/a00054a006df82bb1b5b8f666ae44a676b259146fadbff90fe654309fefc/mlx_metal-0.29.3-py3-none-macosx_13_0_arm64.whl - name: mlx-metal - version: 0.29.3 - sha256: 27b5a4d905202a71e84d9fd559ea0236813f6f960ef494e5cafe9c45df4c9d7c - requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/44/13/7e63cfba8a7452eb756306aa2fd9b37a29a323b672b964b4fdeded9a3f21/scipy-1.16.3-cp313-cp313-macosx_12_0_arm64.whl - name: scipy - version: 1.16.3 - sha256: 16b8bc35a4cc24db80a0ec836a9286d0e31b2503cb2fd7ff7fb0e0374a97081d - requires_dist: - - numpy>=1.25.2,<2.6 - - pytest>=8.0.0 ; extra == 'test' - - pytest-cov ; extra == 'test' - - pytest-timeout ; extra == 'test' - - pytest-xdist ; extra == 'test' - - asv ; extra == 'test' - - mpmath ; extra == 'test' - - gmpy2 ; extra == 'test' - - threadpoolctl ; extra == 'test' - - scikit-umfpack ; extra == 'test' - - pooch ; extra == 'test' - - hypothesis>=6.30 ; extra == 'test' - - array-api-strict>=2.3.1 ; extra == 'test' - - cython ; extra == 'test' - - meson ; extra == 'test' - - ninja ; sys_platform != 'emscripten' and extra == 'test' - - sphinx>=5.0.0,<8.2.0 ; extra == 'doc' - - intersphinx-registry ; extra == 'doc' - - pydata-sphinx-theme>=0.15.2 ; extra == 'doc' - - sphinx-copybutton ; extra == 'doc' - - sphinx-design>=0.4.0 ; extra == 'doc' - - matplotlib>=3.5 ; extra == 'doc' - - numpydoc ; extra == 'doc' - - jupytext ; extra == 'doc' - - myst-nb>=1.2.0 ; extra == 'doc' - - pooch ; extra == 'doc' - - jupyterlite-sphinx>=0.19.1 ; extra == 'doc' - - jupyterlite-pyodide-kernel ; extra == 'doc' - - linkify-it-py ; extra == 'doc' - - mypy==1.10.0 ; extra == 'dev' - - typing-extensions ; extra == 'dev' - - types-psutil ; extra == 'dev' - - pycodestyle ; extra == 'dev' - - ruff>=0.0.292 ; extra == 'dev' - - cython-lint>=0.12.2 ; extra == 'dev' - - rich-click ; extra == 'dev' - - doit>=0.36.0 ; extra == 'dev' - - pydevtool ; extra == 'dev' - requires_python: '>=3.11' -- pypi: https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl - name: cffi - version: 2.0.0 - sha256: 45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca - requires_dist: - - pycparser ; implementation_name != 'PyPy' - requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/51/c7/b64cae5dba3a1b138d7123ec36bb5ccd39d39939f18454407e5468f4763f/fsspec-2025.12.0-py3-none-any.whl - name: fsspec - version: 2025.12.0 - sha256: 8bf1fe301b7d8acfa6e8571e3b1c3d158f909666642431cc78a1b7b4dbc5ec5b - requires_dist: - - adlfs ; extra == 'abfs' - - adlfs ; extra == 'adl' - - pyarrow>=1 ; extra == 'arrow' - - dask ; extra == 'dask' - - distributed ; extra == 'dask' - - pre-commit ; extra == 'dev' - - ruff>=0.5 ; extra == 'dev' - - numpydoc ; extra == 'doc' - - sphinx ; extra == 'doc' - - sphinx-design ; extra == 'doc' - - sphinx-rtd-theme ; extra == 'doc' - - yarl ; extra == 'doc' - - dropbox ; extra == 'dropbox' - - dropboxdrivefs ; extra == 'dropbox' - - requests ; extra == 'dropbox' - - adlfs ; extra == 'full' - - aiohttp!=4.0.0a0,!=4.0.0a1 ; extra == 'full' - - dask ; extra == 'full' - - distributed ; extra == 'full' - - dropbox ; extra == 'full' - - dropboxdrivefs ; extra == 'full' - - fusepy ; extra == 'full' - - gcsfs ; extra == 'full' - - libarchive-c ; extra == 'full' - - ocifs ; extra == 'full' - - panel ; extra == 'full' - - paramiko ; extra == 'full' - - pyarrow>=1 ; extra == 'full' - - pygit2 ; extra == 'full' - - requests ; extra == 'full' - - s3fs ; extra == 'full' - - smbprotocol ; extra == 'full' - - tqdm ; extra == 'full' - - fusepy ; extra == 'fuse' - - gcsfs ; extra == 'gcs' - - pygit2 ; extra == 'git' - - requests ; extra == 'github' - - gcsfs ; extra == 'gs' - - panel ; extra == 'gui' - - pyarrow>=1 ; extra == 'hdfs' - - aiohttp!=4.0.0a0,!=4.0.0a1 ; extra == 'http' - - libarchive-c ; extra == 'libarchive' - - ocifs ; extra == 'oci' - - s3fs ; extra == 's3' - - paramiko ; extra == 'sftp' - - smbprotocol ; extra == 'smb' - - paramiko ; extra == 'ssh' - - aiohttp!=4.0.0a0,!=4.0.0a1 ; extra == 'test' - - numpy ; extra == 'test' - - pytest ; extra == 'test' - - pytest-asyncio!=0.22.0 ; extra == 'test' - - pytest-benchmark ; extra == 'test' - - pytest-cov ; extra == 'test' - - pytest-mock ; extra == 'test' - - pytest-recording ; extra == 'test' - - pytest-rerunfailures ; extra == 'test' - - requests ; extra == 'test' - - aiobotocore>=2.5.4,<3.0.0 ; extra == 'test-downstream' - - dask[dataframe,test] ; extra == 'test-downstream' - - moto[server]>4,<5 ; extra == 'test-downstream' - - pytest-timeout ; extra == 'test-downstream' - - xarray ; extra == 'test-downstream' - - adlfs ; extra == 'test-full' - - aiohttp!=4.0.0a0,!=4.0.0a1 ; extra == 'test-full' - - cloudpickle ; extra == 'test-full' - - dask ; extra == 'test-full' - - distributed ; extra == 'test-full' - - dropbox ; extra == 'test-full' - - dropboxdrivefs ; extra == 'test-full' - - fastparquet ; extra == 'test-full' - - fusepy ; extra == 'test-full' - - gcsfs ; extra == 'test-full' - - jinja2 ; extra == 'test-full' - - kerchunk ; extra == 'test-full' - - libarchive-c ; extra == 'test-full' - - lz4 ; extra == 'test-full' - - notebook ; extra == 'test-full' - - numpy ; extra == 'test-full' - - ocifs ; extra == 'test-full' - - pandas ; extra == 'test-full' - - panel ; extra == 'test-full' - - paramiko ; extra == 'test-full' - - pyarrow ; extra == 'test-full' - - pyarrow>=1 ; extra == 'test-full' - - pyftpdlib ; extra == 'test-full' - - pygit2 ; extra == 'test-full' - - pytest ; extra == 'test-full' - - pytest-asyncio!=0.22.0 ; extra == 'test-full' - - pytest-benchmark ; extra == 'test-full' - - pytest-cov ; extra == 'test-full' - - pytest-mock ; extra == 'test-full' - - pytest-recording ; extra == 'test-full' - - pytest-rerunfailures ; extra == 'test-full' - - python-snappy ; extra == 'test-full' - - requests ; extra == 'test-full' - - smbprotocol ; extra == 'test-full' - - tqdm ; extra == 'test-full' - - urllib3 ; extra == 'test-full' - - zarr ; extra == 'test-full' - - zstandard ; python_full_version < '3.14' and extra == 'test-full' - - tqdm ; extra == 'tqdm' - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/57/f7/e19e6eff445bec52dde5bed1ebb162925a8e6f988164f1ae4b3475a73680/numba-0.63.1-cp313-cp313-macosx_12_0_arm64.whl - name: numba - version: 0.63.1 - sha256: 0bd4fd820ef7442dcc07da184c3f54bb41d2bdb7b35bacf3448e73d081f730dc - requires_dist: - - llvmlite>=0.46.0.dev0,<0.47 - - numpy>=1.22,<2.4 - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/59/4f/ac3fa906ae8a375a536b12794128c5efacade9eaa917a35dfd27ce0c7400/pynput-1.8.1-py2.py3-none-any.whl - name: pynput - version: 1.8.1 - sha256: 42dfcf27404459ca16ca889c8fb8ffe42a9fe54f722fd1a3e130728e59e768d2 - requires_dist: - - six - - evdev>=1.3 ; 'linux' in sys_platform - - python-xlib>=0.17 ; 'linux' in sys_platform - - enum34 ; python_full_version == '2.7.*' - - pyobjc-framework-applicationservices>=8.0 ; sys_platform == 'darwin' - - pyobjc-framework-quartz>=8.0 ; sys_platform == 'darwin' -- pypi: https://files.pythonhosted.org/packages/5f/c3/aeaaf3911d2529614be18d1c8b5496afc185560e76568063d517283318af/huggingface_hub-1.24.0-py3-none-any.whl - name: huggingface-hub - version: 1.24.0 - sha256: 6ed4120a84a6beec900640aa7e346bd766a6b7341e41526fef5dc8bd81fb7d59 - requires_dist: - - click>=8.4.2,<9.0.0 - - filelock>=3.10.0 - - fsspec>=2023.5.0 - - hf-xet>=1.5.1,<2.0.0 ; platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64' - - httpx>=0.23.0,<1 - - packaging>=20.9 - - pyyaml>=5.1 - - tqdm>=4.42.1 - - typing-extensions>=4.1.0 - - authlib>=1.3.2 ; extra == 'oauth' - - fastapi ; extra == 'oauth' - - httpx ; extra == 'oauth' - - itsdangerous ; extra == 'oauth' - - torch ; extra == 'torch' - - safetensors[torch] ; extra == 'torch' - - toml ; extra == 'fastai' - - fastai>=2.4 ; extra == 'fastai' - - fastcore>=1.3.27 ; extra == 'fastai' - - hf-xet>=1.5.1,<2.0.0 ; extra == 'hf-xet' - - mcp>=1.8.0 ; extra == 'mcp' - - authlib>=1.3.2 ; extra == 'testing' - - fastapi ; extra == 'testing' - - httpx ; extra == 'testing' - - itsdangerous ; extra == 'testing' - - jedi ; extra == 'testing' - - jinja2 ; extra == 'testing' - - pytest>=8.4.2 ; extra == 'testing' - - pytest-cov ; extra == 'testing' - - pytest-env ; extra == 'testing' - - pytest-xdist ; extra == 'testing' - - pytest-vcr ; extra == 'testing' - - pytest-asyncio ; extra == 'testing' - - pytest-rerunfailures<16.0 ; extra == 'testing' - - pytest-mock ; extra == 'testing' - - urllib3<2.0 ; extra == 'testing' - - soundfile ; extra == 'testing' - - pillow ; extra == 'testing' - - numpy ; extra == 'testing' - - duckdb ; extra == 'testing' - - fastapi ; extra == 'testing' - - gradio>=5.0.0 ; extra == 'gradio' - - requests ; extra == 'gradio' - - typing-extensions>=4.8.0 ; extra == 'typing' - - types-pyyaml ; extra == 'typing' - - types-simplejson ; extra == 'typing' - - types-toml ; extra == 'typing' - - types-tqdm ; extra == 'typing' - - types-urllib3 ; extra == 'typing' - - ruff>=0.9.0 ; extra == 'quality' - - mypy==1.15.0 ; extra == 'quality' - - libcst>=1.4.0 ; extra == 'quality' - - ty ; extra == 'quality' - - authlib>=1.3.2 ; extra == 'all' - - fastapi ; extra == 'all' - - httpx ; extra == 'all' - - itsdangerous ; extra == 'all' - - jedi ; extra == 'all' - - jinja2 ; extra == 'all' - - pytest>=8.4.2 ; extra == 'all' - - pytest-cov ; extra == 'all' - - pytest-env ; extra == 'all' - - pytest-xdist ; extra == 'all' - - pytest-vcr ; extra == 'all' - - pytest-asyncio ; extra == 'all' - - pytest-rerunfailures<16.0 ; extra == 'all' - - pytest-mock ; extra == 'all' - - urllib3<2.0 ; extra == 'all' - - soundfile ; extra == 'all' - - pillow ; extra == 'all' - - numpy ; extra == 'all' - - duckdb ; extra == 'all' - - fastapi ; extra == 'all' - - ruff>=0.9.0 ; extra == 'all' - - mypy==1.15.0 ; extra == 'all' - - libcst>=1.4.0 ; extra == 'all' - - ty ; extra == 'all' - - typing-extensions>=4.8.0 ; extra == 'all' - - types-pyyaml ; extra == 'all' - - types-simplejson ; extra == 'all' - - types-toml ; extra == 'all' - - types-tqdm ; extra == 'all' - - types-urllib3 ; extra == 'all' - - authlib>=1.3.2 ; extra == 'dev' - - fastapi ; extra == 'dev' - - httpx ; extra == 'dev' - - itsdangerous ; extra == 'dev' - - jedi ; extra == 'dev' - - jinja2 ; extra == 'dev' - - pytest>=8.4.2 ; extra == 'dev' - - pytest-cov ; extra == 'dev' - - pytest-env ; extra == 'dev' - - pytest-xdist ; extra == 'dev' - - pytest-vcr ; extra == 'dev' - - pytest-asyncio ; extra == 'dev' - - pytest-rerunfailures<16.0 ; extra == 'dev' - - pytest-mock ; extra == 'dev' - - urllib3<2.0 ; extra == 'dev' - - soundfile ; extra == 'dev' - - pillow ; extra == 'dev' - - numpy ; extra == 'dev' - - duckdb ; extra == 'dev' - - fastapi ; extra == 'dev' - - ruff>=0.9.0 ; extra == 'dev' - - mypy==1.15.0 ; extra == 'dev' - - libcst>=1.4.0 ; extra == 'dev' - - ty ; extra == 'dev' - - typing-extensions>=4.8.0 ; extra == 'dev' - - types-pyyaml ; extra == 'dev' - - types-simplejson ; extra == 'dev' - - types-toml ; extra == 'dev' - - types-tqdm ; extra == 'dev' - - types-urllib3 ; extra == 'dev' - requires_python: '>=3.10.0' -- pypi: https://files.pythonhosted.org/packages/60/ac/b097a86a1e4a6098f3a79382643ab09d5733d87ccc864877ad1e12b49b70/hf_xet-1.5.2-cp38-abi3-macosx_11_0_arm64.whl - name: hf-xet - version: 1.5.2 - sha256: 045f84440c55cdeb659cf1a1dd48c77bcd0d2e93632e2fea8f2c3bdee79f38ed - requires_dist: - - pytest ; extra == 'tests' - requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl - name: jinja2 - version: 3.1.6 - sha256: 85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67 - requires_dist: - - markupsafe>=2.0 - - babel>=2.7 ; extra == 'i18n' - requires_python: '>=3.7' -- pypi: https://files.pythonhosted.org/packages/63/7a/dfdd6f8c748988427119f75eb860a3cedd858d1aea1fe28f39ad8559ef22/soundfile-0.14.0-py2.py3-none-macosx_11_0_arm64.whl - name: soundfile - version: 0.14.0 - sha256: d828d35a059626da52f1415b5faee610aeab393319cb3fc4a9aef47b619fc14c - requires_dist: - - cffi>=1.0 - - numpy - - typing-extensions - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/64/71/fbcfa83a1d6a9c6091942d1cfd070962244664b87427a9a49a6897b1b219/msgpack-1.2.1-cp313-cp313-macosx_11_0_arm64.whl - name: msgpack - version: 1.2.1 - sha256: 491cc39455ca765fad51fb451bf2915eb2cf41192ab5801ce8d67c1d614fe056 - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl - name: urllib3 - version: 2.6.2 - sha256: ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd - requires_dist: - - brotli>=1.2.0 ; platform_python_implementation == 'CPython' and extra == 'brotli' - - brotlicffi>=1.2.0.0 ; platform_python_implementation != 'CPython' and extra == 'brotli' - - h2>=4,<5 ; extra == 'h2' - - pysocks>=1.5.6,!=1.5.7,<2.0 ; extra == 'socks' - - backports-zstd>=1.0.0 ; python_full_version < '3.14' and extra == 'zstd' - requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/6f/67/8d85ca2323233ae3c0365a659c4e52ee1f587b440e4bc577e7d8e4416d0f/transformers-5.14.1-py3-none-any.whl - name: transformers - version: 5.14.1 - sha256: 9db974c4079ede2d1a3ea7ca5a240df33f2cc26fc2b36ba64c5f2a4f43b6e725 - requires_dist: - - huggingface-hub>=1.5.0,<2.0 - - numpy>=1.17 - - packaging>=20.0 - - pyyaml>=5.1 - - regex>=2025.10.22 - - tokenizers>=0.22.0,<=0.23.0 - - typer - - safetensors>=0.8.0 - - tqdm>=4.60 - - torch>=2.4 ; extra == 'torch' - - accelerate>=1.1.0 ; extra == 'torch' - - torchvision ; extra == 'vision' - - pillow>=10.0.1,<=15.0 ; extra == 'vision' - - torchaudio ; extra == 'audio' - - librosa ; extra == 'audio' - - pyctcdecode>=0.4.0 ; extra == 'audio' - - phonemizer ; extra == 'audio' - - av ; extra == 'video' - - timm>=1.0.23 ; extra == 'timm' - - datasets>=2.15.0 ; extra == 'quality' - - ruff==0.14.10 ; extra == 'quality' - - gitpython<3.1.19 ; extra == 'quality' - - urllib3<2.0.0 ; extra == 'quality' - - libcst ; extra == 'quality' - - rich ; extra == 'quality' - - ty==0.0.20 ; extra == 'quality' - - tomli ; extra == 'quality' - - transformers-mlinter==0.1.2 ; extra == 'quality' - - hf-doc-builder ; extra == 'docs' - - kernels>=0.15.2,<0.16 ; extra == 'kernels' - - sentencepiece>=0.1.91,!=0.1.92 ; extra == 'sentencepiece' - - protobuf ; extra == 'sentencepiece' - - tiktoken ; extra == 'tiktoken' - - blobfile ; extra == 'tiktoken' - - mistral-common[image]>=1.11.5 ; extra == 'mistral-common' - - jinja2>=3.1.0 ; extra == 'chat-template' - - scikit-learn ; extra == 'sklearn' - - accelerate>=1.1.0 ; extra == 'accelerate' - - faiss-cpu ; extra == 'retrieval' - - datasets>=2.15.0 ; extra == 'retrieval' - - sagemaker>=2.31.0 ; extra == 'sagemaker' - - deepspeed>=0.9.3 ; extra == 'deepspeed' - - accelerate>=1.1.0 ; extra == 'deepspeed' - - optuna ; extra == 'optuna' - - kernels>=0.15.2,<0.16 ; extra == 'integrations' - - optuna ; extra == 'integrations' - - codecarbon>=2.8.1 ; extra == 'integrations' - - ray[tune]>=2.7.0 ; extra == 'integrations' - - ray[tune]>=2.7.0 ; extra == 'ray' - - codecarbon>=2.8.1 ; extra == 'codecarbon' - - openai>=1.98.0 ; extra == 'serving' - - pydantic>=2 ; extra == 'serving' - - uvicorn ; extra == 'serving' - - fastapi ; extra == 'serving' - - starlette ; extra == 'serving' - - rich ; extra == 'serving' - - torch>=2.4 ; extra == 'serving' - - accelerate>=1.1.0 ; extra == 'serving' - - num2words ; extra == 'num2words' - - optimum-benchmark>=0.3.0 ; extra == 'benchmark' - - fugashi>=1.0 ; extra == 'ja' - - ipadic>=1.0.0,<2.0 ; extra == 'ja' - - unidic-lite>=1.0.7 ; extra == 'ja' - - unidic>=1.0.2 ; extra == 'ja' - - rhoknp>=1.1.0,<1.3.1 ; extra == 'ja' - - sudachipy>=0.6.6 ; extra == 'ja' - - sudachidict-core>=20220729 ; extra == 'ja' - - pytest>=7.2.0,<9.0.0 ; extra == 'testing' - - pytest-asyncio>=1.2.0 ; extra == 'testing' - - pytest-random-order ; extra == 'testing' - - pytest-rich ; extra == 'testing' - - pytest-xdist ; extra == 'testing' - - pytest-order ; extra == 'testing' - - pytest-rerunfailures<16.0 ; extra == 'testing' - - pytest-timeout ; extra == 'testing' - - pytest-env ; extra == 'testing' - - timeout-decorator ; extra == 'testing' - - parameterized>=0.9 ; extra == 'testing' - - psutil ; extra == 'testing' - - dill<0.3.5 ; extra == 'testing' - - evaluate>=0.4.6 ; extra == 'testing' - - rouge-score!=0.0.7,!=0.0.8,!=0.1,!=0.1.1 ; extra == 'testing' - - nltk<=3.8.1 ; extra == 'testing' - - sacremoses ; extra == 'testing' - - rjieba ; extra == 'testing' - - beautifulsoup4 ; extra == 'testing' - - tensorboard ; extra == 'testing' - - sacrebleu>=1.4.12,<2.0.0 ; extra == 'testing' - - filelock ; extra == 'testing' - - hf-doc-builder ; extra == 'testing' - - datasets>=2.15.0 ; extra == 'testing' - - ruff==0.14.10 ; extra == 'testing' - - gitpython<3.1.19 ; extra == 'testing' - - urllib3<2.0.0 ; extra == 'testing' - - libcst ; extra == 'testing' - - rich ; extra == 'testing' - - ty==0.0.20 ; extra == 'testing' - - tomli ; extra == 'testing' - - transformers-mlinter==0.1.2 ; extra == 'testing' - - faiss-cpu ; extra == 'testing' - - datasets>=2.15.0 ; extra == 'testing' - - sentencepiece>=0.1.91,!=0.1.92 ; extra == 'testing' - - protobuf ; extra == 'testing' - - openai>=1.98.0 ; extra == 'testing' - - pydantic>=2 ; extra == 'testing' - - uvicorn ; extra == 'testing' - - fastapi ; extra == 'testing' - - starlette ; extra == 'testing' - - rich ; extra == 'testing' - - torch>=2.4 ; extra == 'testing' - - accelerate>=1.1.0 ; extra == 'testing' - - mistral-common[image]>=1.11.5 ; extra == 'testing' - - deepspeed>=0.9.3 ; extra == 'deepspeed-testing' - - accelerate>=1.1.0 ; extra == 'deepspeed-testing' - - pytest>=7.2.0,<9.0.0 ; extra == 'deepspeed-testing' - - pytest-asyncio>=1.2.0 ; extra == 'deepspeed-testing' - - pytest-random-order ; extra == 'deepspeed-testing' - - pytest-rich ; extra == 'deepspeed-testing' - - pytest-xdist ; extra == 'deepspeed-testing' - - pytest-order ; extra == 'deepspeed-testing' - - pytest-rerunfailures<16.0 ; extra == 'deepspeed-testing' - - pytest-timeout ; extra == 'deepspeed-testing' - - pytest-env ; extra == 'deepspeed-testing' - - timeout-decorator ; extra == 'deepspeed-testing' - - parameterized>=0.9 ; extra == 'deepspeed-testing' - - psutil ; extra == 'deepspeed-testing' - - dill<0.3.5 ; extra == 'deepspeed-testing' - - evaluate>=0.4.6 ; extra == 'deepspeed-testing' - - rouge-score!=0.0.7,!=0.0.8,!=0.1,!=0.1.1 ; extra == 'deepspeed-testing' - - nltk<=3.8.1 ; extra == 'deepspeed-testing' - - sacremoses ; extra == 'deepspeed-testing' - - rjieba ; extra == 'deepspeed-testing' - - beautifulsoup4 ; extra == 'deepspeed-testing' - - tensorboard ; extra == 'deepspeed-testing' - - sacrebleu>=1.4.12,<2.0.0 ; extra == 'deepspeed-testing' - - filelock ; extra == 'deepspeed-testing' - - hf-doc-builder ; extra == 'deepspeed-testing' - - datasets>=2.15.0 ; extra == 'deepspeed-testing' - - ruff==0.14.10 ; extra == 'deepspeed-testing' - - gitpython<3.1.19 ; extra == 'deepspeed-testing' - - urllib3<2.0.0 ; extra == 'deepspeed-testing' - - libcst ; extra == 'deepspeed-testing' - - rich ; extra == 'deepspeed-testing' - - ty==0.0.20 ; extra == 'deepspeed-testing' - - tomli ; extra == 'deepspeed-testing' - - transformers-mlinter==0.1.2 ; extra == 'deepspeed-testing' - - faiss-cpu ; extra == 'deepspeed-testing' - - datasets>=2.15.0 ; extra == 'deepspeed-testing' - - sentencepiece>=0.1.91,!=0.1.92 ; extra == 'deepspeed-testing' - - protobuf ; extra == 'deepspeed-testing' - - openai>=1.98.0 ; extra == 'deepspeed-testing' - - pydantic>=2 ; extra == 'deepspeed-testing' - - uvicorn ; extra == 'deepspeed-testing' - - fastapi ; extra == 'deepspeed-testing' - - starlette ; extra == 'deepspeed-testing' - - rich ; extra == 'deepspeed-testing' - - torch>=2.4 ; extra == 'deepspeed-testing' - - accelerate>=1.1.0 ; extra == 'deepspeed-testing' - - mistral-common[image]>=1.11.5 ; extra == 'deepspeed-testing' - - optuna ; extra == 'deepspeed-testing' - - sentencepiece>=0.1.91,!=0.1.92 ; extra == 'deepspeed-testing' - - protobuf ; extra == 'deepspeed-testing' - - torch>=2.4 ; extra == 'all' - - accelerate>=1.1.0 ; extra == 'all' - - torchvision ; extra == 'all' - - pillow>=10.0.1,<=15.0 ; extra == 'all' - - torchaudio ; extra == 'all' - - librosa ; extra == 'all' - - pyctcdecode>=0.4.0 ; extra == 'all' - - phonemizer ; extra == 'all' - - av ; extra == 'all' - - kernels>=0.15.2,<0.16 ; extra == 'all' - - timm>=1.0.23 ; extra == 'all' - - sentencepiece>=0.1.91,!=0.1.92 ; extra == 'all' - - protobuf ; extra == 'all' - - tiktoken ; extra == 'all' - - blobfile ; extra == 'all' - - jinja2>=3.1.0 ; extra == 'all' - - num2words ; extra == 'all' - - mistral-common[image]>=1.11.5 ; extra == 'all' - - torch>=2.4 ; extra == 'dev' - - accelerate>=1.1.0 ; extra == 'dev' - - torchvision ; extra == 'dev' - - pillow>=10.0.1,<=15.0 ; extra == 'dev' - - torchaudio ; extra == 'dev' - - librosa ; extra == 'dev' - - pyctcdecode>=0.4.0 ; extra == 'dev' - - phonemizer ; extra == 'dev' - - av ; extra == 'dev' - - kernels>=0.15.2,<0.16 ; extra == 'dev' - - timm>=1.0.23 ; extra == 'dev' - - sentencepiece>=0.1.91,!=0.1.92 ; extra == 'dev' - - protobuf ; extra == 'dev' - - tiktoken ; extra == 'dev' - - blobfile ; extra == 'dev' - - jinja2>=3.1.0 ; extra == 'dev' - - num2words ; extra == 'dev' - - mistral-common[image]>=1.11.5 ; extra == 'dev' - - pytest>=7.2.0,<9.0.0 ; extra == 'dev' - - pytest-asyncio>=1.2.0 ; extra == 'dev' - - pytest-random-order ; extra == 'dev' - - pytest-rich ; extra == 'dev' - - pytest-xdist ; extra == 'dev' - - pytest-order ; extra == 'dev' - - pytest-rerunfailures<16.0 ; extra == 'dev' - - pytest-timeout ; extra == 'dev' - - pytest-env ; extra == 'dev' - - timeout-decorator ; extra == 'dev' - - parameterized>=0.9 ; extra == 'dev' - - psutil ; extra == 'dev' - - dill<0.3.5 ; extra == 'dev' - - evaluate>=0.4.6 ; extra == 'dev' - - rouge-score!=0.0.7,!=0.0.8,!=0.1,!=0.1.1 ; extra == 'dev' - - nltk<=3.8.1 ; extra == 'dev' - - sacremoses ; extra == 'dev' - - rjieba ; extra == 'dev' - - beautifulsoup4 ; extra == 'dev' - - tensorboard ; extra == 'dev' - - sacrebleu>=1.4.12,<2.0.0 ; extra == 'dev' - - filelock ; extra == 'dev' - - hf-doc-builder ; extra == 'dev' - - datasets>=2.15.0 ; extra == 'dev' - - ruff==0.14.10 ; extra == 'dev' - - gitpython<3.1.19 ; extra == 'dev' - - urllib3<2.0.0 ; extra == 'dev' - - libcst ; extra == 'dev' - - rich ; extra == 'dev' - - ty==0.0.20 ; extra == 'dev' - - tomli ; extra == 'dev' - - transformers-mlinter==0.1.2 ; extra == 'dev' - - faiss-cpu ; extra == 'dev' - - datasets>=2.15.0 ; extra == 'dev' - - sentencepiece>=0.1.91,!=0.1.92 ; extra == 'dev' - - protobuf ; extra == 'dev' - - openai>=1.98.0 ; extra == 'dev' - - pydantic>=2 ; extra == 'dev' - - uvicorn ; extra == 'dev' - - fastapi ; extra == 'dev' - - starlette ; extra == 'dev' - - rich ; extra == 'dev' - - torch>=2.4 ; extra == 'dev' - - accelerate>=1.1.0 ; extra == 'dev' - - mistral-common[image]>=1.11.5 ; extra == 'dev' - - fugashi>=1.0 ; extra == 'dev' - - ipadic>=1.0.0,<2.0 ; extra == 'dev' - - unidic-lite>=1.0.7 ; extra == 'dev' - - unidic>=1.0.2 ; extra == 'dev' - - rhoknp>=1.1.0,<1.3.1 ; extra == 'dev' - - sudachipy>=0.6.6 ; extra == 'dev' - - sudachidict-core>=20220729 ; extra == 'dev' - - scikit-learn ; extra == 'dev' - requires_python: '>=3.10.0' -- pypi: https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl - name: certifi - version: 2025.11.12 - sha256: 97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b - requires_python: '>=3.7' -- pypi: https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl - name: filelock - version: 3.20.0 - sha256: 339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2 - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/79/fb/f505c95ceddd7027347b067689db71ca80bd5ecc926f913f1a23e65cf09b/numpy-2.3.5-cp313-cp313-macosx_11_0_arm64.whl - name: numpy - version: 2.3.5 - sha256: aa5bc7c5d59d831d9773d1170acac7893ce3a5e130540605770ade83280e7188 - requires_python: '>=3.11' -- pypi: https://files.pythonhosted.org/packages/7a/90/a5c1084d87767d787a6caba615aa50dc587229646308d9420c960cb5e4c0/standard_chunk-3.13.0-py3-none-any.whl - name: standard-chunk - version: 3.13.0 - sha256: 17880a26c285189c644bd5bd8f8ed2bdb795d216e3293e6dbe55bbd848e2982c -- pypi: https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl - name: joblib - version: 1.5.3 - sha256: 5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713 - requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/7e/16/fbe8e1e185a45042f7cd3a282def5bb8d95bb69ab9e9ef6a5368aa17e426/audioread-3.1.0-py3-none-any.whl - name: audioread - version: 3.1.0 - sha256: b30d1df6c5d3de5dcef0fb0e256f6ea17bdcf5f979408df0297d8a408e2971b4 - requires_dist: - - pygobject>=3.54.2,<4.0.0 ; extra == 'gi' - - pymad[mad]>=0.11.3,<0.12.0 ; extra == 'mad' - - pytest>=8.4.2 ; extra == 'test' - - pytest-cov>=7.0.0 ; extra == 'test' - - standard-aifc ; python_full_version >= '3.13' - - standard-sunau ; python_full_version >= '3.13' - requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/7e/85/a5bfaebfd305ac18b57b0854d74e37e586809061a91fda62f0bd50c8518e/narwhals-2.24.0-py3-none-any.whl - name: narwhals - version: 2.24.0 - sha256: 42fdedf44e5b2ca7505630d45b4ac3058f38d8485cba9fe1652ca23152df7489 - requires_dist: - - cudf-cu12>=24.10.0 ; sys_platform == 'linux' and extra == 'cudf' - - dask[dataframe]>=2024.8 ; extra == 'dask' - - duckdb>=1.1 ; extra == 'duckdb' - - ibis-framework>=6.0.0 ; extra == 'ibis' - - packaging>=21.3 ; extra == 'ibis' - - pyarrow-hotfix>=0.7 ; extra == 'ibis' - - modin>=0.22.0 ; extra == 'modin' - - pandas>=1.3.4 ; extra == 'pandas' - - polars>=0.20.4 ; extra == 'polars' - - pyarrow>=13.0.0 ; extra == 'pyarrow' - - pyspark>=3.5.0 ; extra == 'pyspark' - - pyspark[connect]>=3.5.0 ; extra == 'pyspark-connect' - - narwhals[duckdb] ; extra == 'sql' - - sqlparse>=0.5.5 ; extra == 'sql' - - sqlframe>=3.22.0,!=3.39.3 ; extra == 'sqlframe' - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl - name: httpcore - version: 1.0.9 - sha256: 2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 - requires_dist: - - certifi - - h11>=0.16 - - anyio>=4.0,<5.0 ; extra == 'asyncio' - - h2>=3,<5 ; extra == 'http2' - - socksio==1.* ; extra == 'socks' - - trio>=0.22.0,<1.0 ; extra == 'trio' - requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl - name: anyio - version: 4.12.0 - sha256: dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb - requires_dist: - - exceptiongroup>=1.0.2 ; python_full_version < '3.11' - - idna>=2.8 - - typing-extensions>=4.5 ; python_full_version < '3.13' - - trio>=0.32.0 ; python_full_version >= '3.10' and extra == 'trio' - - trio>=0.31.0 ; python_full_version < '3.10' and extra == 'trio' - requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl - name: rich - version: 15.0.0 - sha256: 33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb - requires_dist: - - ipywidgets>=7.5.1,<9 ; extra == 'jupyter' - - markdown-it-py>=2.2.0 - - pygments>=2.13.0,<3.0.0 - requires_python: '>=3.9.0' -- pypi: https://files.pythonhosted.org/packages/8a/a1/8d812e53a5da1687abb10445275d41a8b13adb781bbf7196ddbcf8d88505/lazy_loader-0.5-py3-none-any.whl - name: lazy-loader - version: '0.5' - sha256: ab0ea149e9c554d4ffeeb21105ac60bed7f3b4fd69b1d2360a4add51b170b005 - requires_dist: - - packaging - - pytest>=8.0 ; extra == 'test' - - pytest-cov>=5.0 ; extra == 'test' - - coverage[toml]>=7.2 ; extra == 'test' - - pre-commit==4.3.0 ; extra == 'lint' - - changelist==0.5 ; extra == 'dev' - - spin==0.15 ; extra == 'dev' - requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/94/35/386550fd60316d1e37eccdda609b074113298f23cef5bddb2049823fe666/dacite-1.9.2-py3-none-any.whl - name: dacite - version: 1.9.2 - sha256: 053f7c3f5128ca2e9aceb66892b1a3c8936d02c686e707bee96e19deef4bc4a0 - requires_dist: - - dataclasses ; python_full_version < '3.7' - - pytest>=5 ; extra == 'dev' - - pytest-benchmark ; extra == 'dev' - - pytest-cov ; extra == 'dev' - - coveralls ; extra == 'dev' - - black ; extra == 'dev' - - mypy ; extra == 'dev' - - pylint ; extra == 'dev' - - pre-commit ; extra == 'dev' - requires_python: '>=3.7' -- pypi: https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl - name: charset-normalizer - version: 3.4.4 - sha256: e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794 - requires_python: '>=3.7' -- pypi: https://files.pythonhosted.org/packages/98/f5/b38da31d9f95770d0f0f9b9724a898d240d6fb630796e04e9682384d086c/pyobjc_framework_avfoundation-12.2.1-cp313-cp313-macosx_10_13_universal2.whl - name: pyobjc-framework-avfoundation - version: 12.2.1 - sha256: 9291848b0f0f66031bd3af2549e1e0cdf43e4872b9c562bd29f0239c6ef2b75f - requires_dist: - - pyobjc-core>=12.2.1 - - pyobjc-framework-coremedia>=12.2.1 - - pyobjc-framework-coreaudio>=12.2.1 - - pyobjc-framework-cocoa>=12.2.1 - - pyobjc-framework-quartz>=12.2.1 - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl - name: markupsafe - version: 3.0.3 - sha256: 116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219 - requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl - name: pycparser - version: '2.23' - sha256: e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934 - requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/a3/88/1a3ea5672f4b0a84802ee9891b86743438e7c04eb0b8f8c4e16a42375327/regex-2025.11.3-cp313-cp313-macosx_11_0_arm64.whl - name: regex - version: 2025.11.3 - sha256: 04d2765516395cf7dda331a244a3282c0f5ae96075f728629287dfa6f76ba70a - requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl - name: pyyaml - version: 6.0.3 - sha256: 2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 - requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl - name: mdurl - version: 0.1.2 - sha256: 84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 - requires_python: '>=3.7' -- pypi: https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl - name: markdown-it-py - version: 4.2.0 - sha256: 9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a - requires_dist: - - mdurl~=0.1 - - psutil ; extra == 'benchmarking' - - pytest ; extra == 'benchmarking' - - pytest-benchmark ; extra == 'benchmarking' - - commonmark~=0.9 ; extra == 'compare' - - markdown~=3.4 ; extra == 'compare' - - mistletoe~=1.0 ; extra == 'compare' - - mistune~=3.0 ; extra == 'compare' - - panflute~=2.3 ; extra == 'compare' - - markdown-it-pyrs ; extra == 'compare' - - linkify-it-py>=1,<3 ; extra == 'linkify' - - mdit-py-plugins>=0.5.0 ; extra == 'plugins' - - gprof2dot ; extra == 'profiling' - - mdit-py-plugins>=0.5.0 ; extra == 'rtd' - - myst-parser ; extra == 'rtd' - - pyyaml ; extra == 'rtd' - - sphinx ; extra == 'rtd' - - sphinx-copybutton ; extra == 'rtd' - - sphinx-design ; extra == 'rtd' - - sphinx-book-theme~=1.0 ; extra == 'rtd' - - jupyter-sphinx ; extra == 'rtd' - - ipykernel ; extra == 'rtd' - - coverage ; extra == 'testing' - - pytest ; extra == 'testing' - - pytest-cov ; extra == 'testing' - - pytest-regressions ; extra == 'testing' - - pytest-timeout ; extra == 'testing' - - requests ; extra == 'testing' - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/b5/ba/c63c5786dfee4c3417094c4b00966e61e4a63efecee22cb7b4c0387dda83/librosa-0.11.0-py3-none-any.whl - name: librosa - version: 0.11.0 - sha256: 0b6415c4fd68bff4c29288abe67c6d80b587e0e1e2cfb0aad23e4559504a7fa1 - requires_dist: - - audioread>=2.1.9 - - numba>=0.51.0 - - numpy>=1.22.3 - - scipy>=1.6.0 - - scikit-learn>=1.1.0 - - joblib>=1.0 - - decorator>=4.3.0 - - soundfile>=0.12.1 - - pooch>=1.1 - - soxr>=0.3.2 - - typing-extensions>=4.1.1 - - lazy-loader>=0.1 - - msgpack>=1.0 - - standard-aifc ; python_full_version >= '3.13' - - standard-sunau ; python_full_version >= '3.13' - - numpydoc ; extra == 'docs' - - sphinx!=1.3.1 ; extra == 'docs' - - sphinx-rtd-theme>=1.2.0 ; extra == 'docs' - - numba>=0.51 ; extra == 'docs' - - matplotlib>=3.5.0 ; extra == 'docs' - - sphinx-multiversion>=0.2.3 ; extra == 'docs' - - sphinx-gallery>=0.7 ; extra == 'docs' - - mir-eval>=0.5 ; extra == 'docs' - - ipython>=7.0 ; extra == 'docs' - - sphinxcontrib-svg2pdfconverter ; extra == 'docs' - - sphinxcontrib-googleanalytics>=0.4 ; extra == 'docs' - - presets ; extra == 'docs' - - sphinx-copybutton>=0.5.2 ; extra == 'docs' - - matplotlib>=3.5.0 ; extra == 'tests' - - packaging>=20.0 ; extra == 'tests' - - pytest-mpl ; extra == 'tests' - - pytest-cov ; extra == 'tests' - - pytest ; extra == 'tests' - - samplerate ; extra == 'tests' - - resampy>=0.2.2 ; extra == 'tests' - - types-decorator ; extra == 'tests' - - matplotlib>=3.5.0 ; extra == 'display' - requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl - name: six - version: 1.17.0 - sha256: 4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 - requires_python: '>=2.7,!=3.0.*,!=3.1.*,!=3.2.*' -- pypi: https://files.pythonhosted.org/packages/c3/52/5fbb203394cc852334d1575cc020f6bcec768d2265355984dfd361968f36/standard_aifc-3.13.0-py3-none-any.whl - name: standard-aifc - version: 3.13.0 - sha256: f7ae09cc57de1224a0dd8e3eb8f73830be7c3d0bc485de4c1f82b4a7f645ac66 - requires_dist: - - standard-chunk ; python_full_version >= '3.13' - - audioop-lts ; python_full_version >= '3.13' -- pypi: https://files.pythonhosted.org/packages/cc/46/68e8e4d926a2f70fed0437047bc3f9fe08af8fe620d94d80656ebc3cfa9b/pyobjc_framework_cocoa-12.2.1-cp313-cp313-macosx_10_13_universal2.whl - name: pyobjc-framework-cocoa - version: 12.2.1 - sha256: 3b74a78fa7803e547b32e5e8ec1b49987b52fe318383e793bc6cd49b80efbd9f - requires_dist: - - pyobjc-core>=12.2.1 - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl - name: tqdm - version: 4.67.1 - sha256: 26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2 - requires_dist: - - colorama ; sys_platform == 'win32' - - pytest>=6 ; extra == 'dev' - - pytest-cov ; extra == 'dev' - - pytest-timeout ; extra == 'dev' - - pytest-asyncio>=0.24 ; extra == 'dev' - - nbval ; extra == 'dev' - - requests ; extra == 'discord' - - slack-sdk ; extra == 'slack' - - requests ; extra == 'telegram' - - ipywidgets>=6 ; extra == 'notebook' - requires_python: '>=3.7' -- pypi: https://files.pythonhosted.org/packages/d6/2a/9e5beae2961c9d22ce16258f39edae69996ee0167dd4cfe4e771454086c1/pyobjc_framework_coremedia-12.2.1-cp313-cp313-macosx_10_13_universal2.whl - name: pyobjc-framework-coremedia - version: 12.2.1 - sha256: 45878f686ce8ea1735ce382b34ef3a5852cafe0ae2a27a49c08701f4c3ab830b - requires_dist: - - pyobjc-core>=12.2.1 - - pyobjc-framework-cocoa>=12.2.1 - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl - name: shellingham - version: 1.5.4 - sha256: 7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686 - requires_python: '>=3.7' -- pypi: https://files.pythonhosted.org/packages/e5/53/913099c91d384e115ea078325efd9a0bc1ea3eb3458c694b4596cbd267f2/mlx_lm-0.29.1-py3-none-any.whl - name: mlx-lm - version: 0.29.1 - sha256: 440941b3054c2a2216e97615de584cc90fa1ea874782e20699b9895721fad8dc - requires_dist: - - mlx>=0.29.2 ; sys_platform == 'darwin' - - numpy - - transformers>=4.39.3 - - sentencepiece - - protobuf - - pyyaml - - jinja2 - - datasets ; extra == 'test' - - lm-eval ; extra == 'test' - - datasets ; extra == 'train' - - tqdm ; extra == 'train' - - lm-eval ; extra == 'evaluate' - - tqdm ; extra == 'evaluate' - - mlx[cuda]>=0.29.2 ; extra == 'cuda' - - mlx[cpu]>=0.29.2 ; extra == 'cpu' - requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/e6/ff/3eba7eb0aed4b6fca37125387cd417e8c458e750621fce56d2c541f67fa8/llvmlite-0.46.0-cp313-cp313-macosx_12_0_arm64.whl - name: llvmlite - version: 0.46.0 - sha256: 30b60892d034bc560e0ec6654737aaa74e5ca327bd8114d82136aa071d611172 - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/ec/73/6fd0bb9ce84138c3857f12e9de63bc901852975a092d545f18087a204aa2/platformdirs-4.10.1-py3-none-any.whl - name: platformdirs - version: 4.10.1 - sha256: 0e4eff26be2d75293977f7cddc153fd9b8eaa7fb0c7b64ffe4076cb443117443 - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/f4/54/09a3501c74f1ef54a36002534661cb89a0b2f098fcad87fe64f515a0dba7/parakeet_mlx-0.5.2-py3-none-any.whl - name: parakeet-mlx - version: 0.5.2 - sha256: 50afb6ddb62237a6486e214482c25ef12759832fda3cd514e159938fa5970d9c - requires_dist: - - dacite>=1.9.2 - - huggingface-hub>=0.30.2 - - librosa>=0.11.0 - - mlx>=0.22.1 - - numpy>=2.2.5 - - typer>=0.15.3 - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl - name: pygments - version: 2.20.0 - sha256: 81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 - requires_dist: - - colorama>=0.4.6 ; extra == 'windows-terminal' - requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/f5/b1/fa7c600e7dceae12e9606c7578cbc9ff1e1ed55844883ee5c92205e86226/safetensors-0.8.0-cp310-abi3-macosx_11_0_arm64.whl - name: safetensors - version: 0.8.0 - sha256: c80201d22cbf405b80647a60ada77bba06c8fba2da2743ba1e89cdcc39a81f25 - requires_dist: - - safetensors[torch] ; extra == 'all' - - safetensors[numpy] ; extra == 'all' - - safetensors[jax] ; extra == 'all' - - safetensors[paddlepaddle] ; extra == 'all' - - safetensors[convert] ; extra == 'all' - - safetensors[quality] ; extra == 'all' - - safetensors[testing] ; extra == 'all' - - safetensors[torch] ; extra == 'convert' - - huggingface-hub>=1.4 ; extra == 'convert' - - safetensors[all] ; extra == 'dev' - - safetensors[pinned-tf] ; extra == 'dev' - - safetensors[numpy] ; extra == 'jax' - - flax>=0.6.3 ; extra == 'jax' - - jax>=0.3.25 ; extra == 'jax' - - jaxlib>=0.3.25 ; extra == 'jax' - - mlx>=0.0.9 ; extra == 'mlx' - - numpy>=1.24.6 ; extra == 'numpy' - - safetensors[numpy] ; extra == 'paddlepaddle' - - paddlepaddle>=2.4.1 ; extra == 'paddlepaddle' - - safetensors[numpy] ; extra == 'pinned-tf' - - tensorflow==2.18.0 ; extra == 'pinned-tf' - - ruff ; extra == 'quality' - - safetensors[numpy] ; extra == 'tensorflow' - - tensorflow>=2.11.0 ; extra == 'tensorflow' - - safetensors[numpy] ; extra == 'testing' - - h5py>=3.7.0 ; extra == 'testing' - - setuptools-rust>=1.12.0 ; extra == 'testing' - - pytest>=9.0 ; extra == 'testing' - - pytest-benchmark>=5.2 ; extra == 'testing' - - hypothesis>=6.70.2 ; extra == 'testing' - - fsspec>=2024.6.0 ; extra == 'testing' - - s3fs>=2024.6.0 ; extra == 'testing' - - safetensors[numpy] ; extra == 'tf-nightly' - - tf-nightly ; extra == 'tf-nightly' - - safetensors[numpy] ; extra == 'torch' - - torch>=2.4 ; extra == 'torch' - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl - name: click - version: 8.4.2 - sha256: e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76 - requires_dist: - - colorama ; sys_platform == 'win32' - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/fc/21/79e42ee836f1010f5fe9e97d2817a006736bd287c15a3674c399190a2e77/pyobjc_framework_applicationservices-12.1-cp313-cp313-macosx_10_13_universal2.whl - name: pyobjc-framework-applicationservices - version: '12.1' - sha256: bd1f4dbb38234a24ae6819f5e22485cf7dd3dd4074ff3bf9a9fdb4c01a3b4a38 - requires_dist: - - pyobjc-core>=12.1 - - pyobjc-framework-cocoa>=12.1 - - pyobjc-framework-quartz>=12.1 - - pyobjc-framework-coretext>=12.1 - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/fe/a2/078152b45aa8a23949a1b09601d0044f8bb4ab85e909e4475a440c21aaea/mlx-0.29.3-cp313-cp313-macosx_13_0_arm64.whl - name: mlx - version: 0.29.3 - sha256: d59eccf6a1e1e131becc5a3910504507862da3a4e9b7bd9e73a625515d767844 - requires_dist: - - mlx-metal==0.29.3 ; sys_platform == 'darwin' - - nanobind==2.4.0 ; extra == 'dev' - - numpy ; extra == 'dev' - - pre-commit ; extra == 'dev' - - setuptools>=80 ; extra == 'dev' - - torch ; extra == 'dev' - - typing-extensions ; extra == 'dev' - - mlx-cuda==0.29.3 ; sys_platform == 'linux' and extra == 'cuda' - - mlx-cpu==0.29.3 ; sys_platform == 'linux' and extra == 'cpu' - requires_python: '>=3.9' diff --git a/pyproject.toml b/pyproject.toml index 1bcae33..f5592ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,9 +36,6 @@ build-backend = "hatchling.build" packages = ["v2t"] exclude = ["v2t/bench.py"] -[tool.hatch.build.targets.wheel.force-include] -"swiftbar/v2t.5s.sh" = "swiftbar/v2t.5s.sh" - [tool.hatch.build.targets.sdist] include = [ "/CHANGELOG.md", @@ -47,22 +44,6 @@ include = [ "/benchmarks", "/justfile", "/pyproject.toml", - "/swiftbar", "/tests", "/v2t", ] - -[tool.pixi.workspace] -channels = ["conda-forge"] -platforms = ["osx-arm64"] - -[tool.pixi.dependencies] -python = ">=3.11,<3.14" -ollama = "*" -# nowplaying-cli not on conda-forge, install via: brew install nowplaying-cli - -[tool.pixi.pypi-dependencies] -voice2text = { path = ".", editable = true } - -[tool.pixi.tasks] -v2t = "v2t" diff --git a/swiftbar/v2t.5s.sh b/swiftbar/v2t.5s.sh deleted file mode 100755 index 678c3ed..0000000 --- a/swiftbar/v2t.5s.sh +++ /dev/null @@ -1,125 +0,0 @@ -#!/bin/bash -# -# v2t -# 0.3.0 -# lucharo -# Toggle and monitor voice2text from the menu bar. -# v2t -# true -# true -# true -# -# Install: brew install swiftbar, then drop this file in your SwiftBar plugins -# folder (keep the `.5s.sh` suffix — SwiftBar refreshes the live state every 5s). -# -# v2t needs three permissions, granted to the app that LAUNCHES it (your terminal, -# or SwiftBar if you use "Start v2t"): Microphone (to record), Accessibility + -# Input Monitoring (to read the hotkey and paste). Use the Permissions submenu -# below, then RESTART that app — macOS only applies the grant on relaunch. - -export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" -umask 077 -if [ -z "${V2T_HOME:-}" ]; then - V2T_HOME="${XDG_CONFIG_HOME:+$XDG_CONFIG_HOME/v2t}" - V2T_HOME="${V2T_HOME:-$HOME/.v2t}" -fi -export V2T_HOME -mkdir -p "$V2T_HOME/run" "$V2T_HOME/history" -chmod 700 "$V2T_HOME" "$V2T_HOME/run" "$V2T_HOME/history" -SEC="x-apple.systempreferences:com.apple.preference.security" -SERVICE_PLIST="$HOME/Library/LaunchAgents/com.lucharo.voice2text.plist" - -V2T_BIN="$(command -v v2t || true)" -for c in "$HOME/.local/bin/v2t" /opt/homebrew/bin/v2t /usr/local/bin/v2t; do - [ -z "$V2T_BIN" ] && [ -x "$c" ] && V2T_BIN="$c" -done - -if [ -z "$V2T_BIN" ]; then - echo "🎙️ ⚠️" - echo "---" - echo "v2t not found on PATH" - echo "Install | href=https://github.com/lucharo/voice2text" - exit 0 -fi - -# SwiftBar re-invokes this script with a param when a menu item is clicked. -case "$1" in - start) - LOG="$V2T_HOME/run/v2t.log" - [ -f "$LOG" ] && [ "$(wc -c <"$LOG")" -gt 1048576 ] && mv -f "$LOG" "$LOG.1" - touch "$LOG"; chmod 600 "$LOG" - if [ -f "$SERVICE_PLIST" ]; then - "$V2T_BIN" service start >>"$LOG" 2>&1 - else - nohup "$V2T_BIN" >>"$LOG" 2>&1 & - fi - exit 0 - ;; - stop) - "$V2T_BIN" stop >/dev/null 2>&1 - exit 0 - ;; -esac - -# `v2t status` -> state, models, mode, error, then the three permission states. -if ! STATUS="$("$V2T_BIN" status 2>/dev/null)"; then - STATUS="$(printf 'config-error\t?\t?\t?\tv2t status failed — check config or log\tunknown\tunknown\tunknown')" -fi -STATE="$(printf '%s' "$STATUS" | cut -f1)" -STT="$(printf '%s' "$STATUS" | cut -f2)" -CLEANUP="$(printf '%s' "$STATUS" | cut -f3)" -MODE="$(printf '%s' "$STATUS" | cut -f4)" -ERROR="$(printf '%s' "$STATUS" | cut -f5)" -MIC_PERMISSION="$(printf '%s' "$STATUS" | cut -f6)" -AX_PERMISSION="$(printf '%s' "$STATUS" | cut -f7)" -INPUT_PERMISSION="$(printf '%s' "$STATUS" | cut -f8)" -[ "$CLEANUP" = "off" ] && CLEAN_LBL="no cleanup" || CLEAN_LBL="clean: $CLEANUP" -[ "$CLEANUP" = "off" ] && START_STEPS=1 || START_STEPS=2 - -permission_item() { - NAME="$1"; STATE="$2"; PANE="$3" - case "$STATE" in - granted) ICON="✓"; LABEL="Granted" ;; - not-requested) ICON="○"; LABEL="Start v2t to request" ;; - denied) ICON="✕"; LABEL="Denied" ;; - restricted) ICON="✕"; LABEL="Restricted" ;; - missing) ICON="○"; LABEL="Not granted" ;; - *) ICON="?"; LABEL="Unknown" ;; - esac - echo "$ICON $NAME · $LABEL | bash=/usr/bin/open param0=\"$SEC?$PANE\" terminal=false" -} - -case "$STATE" in - loading-stt) TITLE="🎙️🟠"; LINE="🟠 Starting 1/$START_STEPS · Loading $STT…" ; RUN=1 ;; - loading-cleanup) TITLE="🎙️🟠"; LINE="🟠 Starting 2/2 · Loading $CLEANUP…" ; RUN=1 ;; - idle) TITLE="🎙️🟢"; LINE="🟢 Ready ($MODE)" ; RUN=1 ;; - recording) TITLE="🎙️🔴"; LINE="🔴 Recording…" ; RUN=1 ;; - transcribing) TITLE="🎙️🟡"; LINE="🟡 Transcribing…" ; RUN=1 ;; - cleaning) TITLE="🎙️🟡"; LINE="🟡 Cleaning up…" ; RUN=1 ;; - stopping) TITLE="🎙️🟠"; LINE="🟠 Finishing current transcription…" ; RUN=1 ;; - error) TITLE="🎙️⚠️"; LINE="⚠️ $ERROR" ; RUN=1 ;; - launch-error) TITLE="🎙️⚠️"; LINE="⚠️ $ERROR" ; RUN=0 ;; - config-error) TITLE="🎙️⚠️"; LINE="⚠️ $ERROR" ; RUN=0 ;; - *) TITLE="🎙️"; LINE="○ Off" ; RUN=0 ;; -esac - -echo "$TITLE" -echo "---" -echo "$LINE" -echo "$STT · $CLEAN_LBL | color=#8e8e93 size=12" -if [ "$RUN" = "1" ]; then - echo "Stop v2t | bash=\"$0\" param0=stop terminal=false refresh=true" -else - echo "Start v2t | bash=\"$0\" param0=start terminal=false refresh=true" -fi -[ -f "$SERVICE_PLIST" ] && echo "Start at login: on | color=#8e8e93 size=12" || echo "Start at login: off | color=#8e8e93 size=12" -echo "---" -echo "Permissions" -permission_item "🎙️ Microphone" "$MIC_PERMISSION" "Privacy_Microphone" -permission_item "♿ Accessibility" "$AX_PERMISSION" "Privacy_Accessibility" -permission_item "⌨️ Input Monitoring" "$INPUT_PERMISSION" "Privacy_ListenEvent" -echo "---" -echo "Config | bash=/usr/bin/open param0=\"$V2T_HOME\" terminal=false" -echo "Transcription History | bash=/usr/bin/open param0=\"$V2T_HOME/history\" terminal=false" -[ -f "$V2T_HOME/run/v2t.log" ] && echo "Log | bash=/usr/bin/open param0=\"$V2T_HOME/run/v2t.log\" terminal=false" -echo "Refresh | refresh=true" diff --git a/tests/test_smoke.py b/tests/test_smoke.py index b54cdda..a01a3e8 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -16,7 +16,7 @@ import numpy as np -from v2t import app, backends, bench, cli, config, permissions, service +from v2t import app, backends, bench, cli, config, menubar, permissions, service class V2TSmokeTests(unittest.TestCase): @@ -143,22 +143,13 @@ def test_status_reports_the_running_overrides(self): voice._set_state("idle") output = io.StringIO() with ( - mock.patch.object( - permissions, - "statuses", - return_value={ - "microphone": "granted", - "accessibility": "granted", - "input": "granted", - }, - ), contextlib.redirect_stdout(output), ): cli.cmd_status([]) self.assertEqual( output.getvalue(), - "idle\tparakeet-v3\toff\tcasual\t\tgranted\tgranted\tgranted\n", + "idle\tparakeet-v3\toff\tcasual\t\n", ) def test_recording_cannot_restart_before_processing_begins(self): @@ -225,11 +216,17 @@ def test_clipboard_is_restored_when_paste_fails(self): NSPasteboardItem=item_class, NSPasteboardTypeString="public.utf8-plain-text", ) + fake_quartz = types.SimpleNamespace( + CGEventCreateKeyboardEvent=lambda *_args: object(), + CGEventPost=lambda *_args: None, + CGEventSetFlags=mock.Mock(side_effect=RuntimeError("paste failed")), + kCGEventFlagMaskCommand=1, + kCGHIDEventTap=0, + ) with ( - mock.patch.dict(sys.modules, {"AppKit": fake_appkit}), - mock.patch.object( - app.subprocess, "run", side_effect=RuntimeError("paste failed") + mock.patch.dict( + sys.modules, {"AppKit": fake_appkit, "Quartz": fake_quartz} ), self.assertRaisesRegex(RuntimeError, "paste failed"), ): @@ -268,6 +265,13 @@ def test_second_instance_lock_is_rejected(self): with self.assertRaises(BlockingIOError): config.acquire_instance_lock() + def test_stale_status_cannot_reuse_an_unrelated_live_pid(self): + config.ensure_dirs() + config.write_status({"pid": os.getpid(), "state": "idle"}) + + self.assertIsNone(config.read_status()) + self.assertFalse((config.run_dir() / "status.json").exists()) + def test_missing_optional_stt_is_rendered_as_not_available(self): samples = [("short", Path("short.wav"), 1.0)] with mock.patch.object(backends, "make_stt", side_effect=SystemExit("missing")): @@ -291,32 +295,97 @@ def test_setup_recommends_the_real_parakeet_install(self): self.assertNotIn("voice2text[parakeet]", output.getvalue()) self.assertEqual(stat.S_IMODE(config.config_path().stat().st_mode), 0o600) - def test_swiftbar_command_installs_the_bundled_plugin(self): - plugin_dir = Path(self.tempdir.name) / "plugins" - cli.cmd_swiftbar(["--dir", str(plugin_dir)]) + def test_menubar_install_builds_a_grantable_native_app(self): + destination = Path(self.tempdir.name) / "Voice2Text.app" - installed = plugin_dir / "v2t.5s.sh" - source = Path(cli.__file__).resolve().parent.parent / "swiftbar" / "v2t.5s.sh" - self.assertEqual(installed.read_text(), source.read_text()) - self.assertEqual(stat.S_IMODE(installed.stat().st_mode), 0o755) + def compile_app(command, **_kwargs): + if command[0] == "xcrun": + Path(command[command.index("-o") + 1]).touch(mode=0o755) + return mock.Mock(returncode=0) + + with ( + mock.patch.object(menubar, "app_path", return_value=destination), + mock.patch.object(menubar, "signing_identity", return_value="-"), + mock.patch.object(menubar.subprocess, "run", side_effect=compile_app), + mock.patch.dict( + os.environ, {"V2T_HOME": self.tempdir.name}, clear=True + ), + ): + installed = menubar.install() + + info = plistlib.loads( + (installed / "Contents" / "Info.plist").read_bytes() + ) + self.assertEqual( + info, + { + "CFBundleDevelopmentRegion": "en", + "CFBundleExecutable": "Voice2Text", + "CFBundleIdentifier": "com.lucharo.voice2text", + "CFBundleInfoDictionaryVersion": "6.0", + "CFBundleName": "Voice2Text", + "CFBundlePackageType": "APPL", + "CFBundleShortVersionString": "0.3.0", + "CFBundleVersion": "1", + "LSMinimumSystemVersion": "13.0", + "LSUIElement": True, + "NSMicrophoneUsageDescription": "Voice2Text uses the microphone for fully local transcription.", + "NSPrincipalClass": "NSApplication", + "V2THome": self.tempdir.name, + "V2TPythonExecutable": sys.executable, + }, + ) + self.assertEqual( + stat.S_IMODE( + (installed / "Contents" / "MacOS" / "Voice2Text").stat().st_mode + ), + 0o755, + ) + + def test_menubar_prefers_a_stable_apple_development_signature(self): + output = """\ + 1) ABCDEF \"Apple Development: Developer (TEAMID)\" + 2) 123456 \"Apple Distribution: Developer (TEAMID)\" + 2 valid identities found +""" + with mock.patch.object( + menubar.subprocess, + "run", + return_value=mock.Mock(stdout=output), + ): + self.assertEqual( + menubar.signing_identity(), + "Apple Development: Developer (TEAMID)", + ) def test_launch_agent_keeps_one_warm_v2t_process(self): plist = Path(self.tempdir.name) / "com.lucharo.voice2text.plist" + app_executable = Path(self.tempdir.name) / "Voice2Text.app/Contents/MacOS/Voice2Text" + app_executable.parent.mkdir(parents=True) + app_executable.touch() with ( mock.patch.object(service, "plist_path", return_value=plist), + mock.patch.object(menubar, "app_executable", return_value=app_executable), mock.patch.object(service, "loaded", return_value=False), mock.patch.object(service, "service_pid", return_value=None), - mock.patch.object(service, "start") as start, + mock.patch.object(service, "_launchctl") as launchctl, mock.patch.object(service.sys, "platform", "darwin"), ): service.install() data = plistlib.loads(plist.read_bytes()) - self.assertEqual(data["ProgramArguments"], [sys.executable, "-m", "v2t"]) + self.assertEqual( + data["ProgramArguments"], + [ + str(app_executable), + "--start", + ], + ) self.assertTrue(data["RunAtLoad"]) self.assertNotIn("KeepAlive", data) - self.assertEqual(data["EnvironmentVariables"]["V2T_HOME"], self.tempdir.name) - start.assert_called_once_with() + launchctl.assert_called_once_with( + "bootstrap", f"gui/{os.getuid()}", str(plist) + ) def test_service_start_does_not_restart_a_healthy_process(self): plist = Path(self.tempdir.name) / "com.lucharo.voice2text.plist" diff --git a/v2t/app.py b/v2t/app.py index 342b9be..36ac5d3 100644 --- a/v2t/app.py +++ b/v2t/app.py @@ -100,7 +100,7 @@ def __init__(self, cfg: Config): "mode": cfg.mode, } - # --- live status for the SwiftBar plugin ------------------------------- + # --- live status for CLI and menu-bar clients -------------------------- # state: starting | idle | recording | transcribing | cleaning. Written on # every transition (and the icon repainted) so actions get instant feedback. def _set_state(self, state: str, error: str = "") -> None: @@ -283,6 +283,13 @@ def process_audio(self, frames: list[np.ndarray], audio_s: float): def paste_to_cursor(self, text: str) -> None: """Paste at the cursor, preserving every native pasteboard representation.""" from AppKit import NSPasteboard, NSPasteboardItem, NSPasteboardTypeString + from Quartz import ( + CGEventCreateKeyboardEvent, + CGEventPost, + CGEventSetFlags, + kCGEventFlagMaskCommand, + kCGHIDEventTap, + ) pasteboard = NSPasteboard.generalPasteboard() saved = [] @@ -296,14 +303,12 @@ def paste_to_cursor(self, text: str) -> None: pasteboard.clearContents() if not pasteboard.setString_forType_(text, NSPasteboardTypeString): raise RuntimeError("could not write to the clipboard") - subprocess.run( - [ - "osascript", - "-e", - 'tell application "System Events" to keystroke "v" using command down', - ], - check=True, - ) + down = CGEventCreateKeyboardEvent(None, 9, True) + up = CGEventCreateKeyboardEvent(None, 9, False) + CGEventSetFlags(down, kCGEventFlagMaskCommand) + CGEventSetFlags(up, kCGEventFlagMaskCommand) + CGEventPost(kCGHIDEventTap, down) + CGEventPost(kCGHIDEventTap, up) # Give slower targets time to consume the synthetic paste before restoration. time.sleep(0.3) finally: diff --git a/v2t/cli.py b/v2t/cli.py index c3e946a..723467f 100644 --- a/v2t/cli.py +++ b/v2t/cli.py @@ -5,8 +5,8 @@ v2t config show resolved config + paths (--init to write a template) v2t status live state line (off / starting / idle / recording / …) v2t stop stop a running v2t - v2t service install/control an optional launch-at-login service - v2t swiftbar install/update the bundled SwiftBar plugin + v2t menubar install/open the optional native menu-bar app + v2t service start the menu app automatically at login """ from __future__ import annotations @@ -15,7 +15,6 @@ import os import signal import sys -from pathlib import Path from . import config @@ -77,7 +76,8 @@ def cmd_run(argv: list[str]) -> int: "nowplaying-cli not found (brew install nowplaying-cli) — music pause disabled." ) cfg.pause_music = False - app.check_and_request_permissions() + if os.environ.get("V2T_LAUNCH_CONTEXT") != "menubar": + app.check_and_request_permissions() app.VoiceToText(cfg).run() return 0 @@ -184,8 +184,8 @@ def cmd_setup(argv: list[str]) -> int: def cmd_status(argv: list[str]) -> int: - """For SwiftBar: runtime state, models, error, and native permission grants.""" - from . import backends, permissions + """Runtime state, models, mode, and any launch error.""" + from . import backends s = config.read_status() if s: @@ -211,7 +211,6 @@ def cmd_status(argv: list[str]) -> int: else "off" ) mode = cfg.mode - grants = permissions.statuses() fields = [ " ".join(str(value).replace("|", "/").split()) for value in ( @@ -220,9 +219,6 @@ def cmd_status(argv: list[str]) -> int: cleanup, mode, error, - grants["microphone"], - grants["accessibility"], - grants["input"], ) ] print("\t".join(fields)) @@ -240,31 +236,22 @@ def cmd_stop(argv: list[str]) -> int: return 0 -def cmd_swiftbar(argv: list[str]) -> int: - from shutil import copy2 +def cmd_menubar(argv: list[str]) -> int: + from . import menubar - default_dir = Path( - os.environ.get( - "SWIFTBAR_PLUGIN_DIR", - "~/Library/Application Support/SwiftBar/Plugins", - ) - ).expanduser() parser = argparse.ArgumentParser( - prog="v2t swiftbar", description="install/update the SwiftBar plugin" + prog="v2t menubar", description="install or open the optional menu-bar app" ) parser.add_argument( - "--dir", type=Path, default=default_dir, help="SwiftBar plugins directory" + "action", nargs="?", choices=["install", "open"], default="install" ) args = parser.parse_args(argv) - - source = Path(__file__).resolve().parent.parent / "swiftbar" / "v2t.5s.sh" - if not source.exists(): - raise SystemExit("bundled SwiftBar plugin is missing; reinstall voice2text") - args.dir.mkdir(parents=True, exist_ok=True) - destination = args.dir / source.name - copy2(source, destination) - destination.chmod(0o755) - print(f"installed {destination}") + if args.action == "install": + path = menubar.install() + print(f"installed {path}") + menubar.open_app() + else: + menubar.open_app() return 0 @@ -282,8 +269,8 @@ def cmd_service(argv: list[str]) -> int: try: if args.action == "install": path = service.install() - print(f"installed and started {path}") - print(f"service executable: {sys.executable}") + print(f"installed for login {path}") + print(f"menu app: {service.menubar.app_path()}") return 0 getattr(service, args.action)() except RuntimeError as error: @@ -306,7 +293,7 @@ def main(argv: list[str] | None = None) -> int: "status": cmd_status, "stop": cmd_stop, "service": cmd_service, - "swiftbar": cmd_swiftbar, + "menubar": cmd_menubar, } if argv and argv[0] in table: return table[argv[0]](argv[1:]) diff --git a/v2t/config.py b/v2t/config.py index cca3e8f..dcaa53f 100644 --- a/v2t/config.py +++ b/v2t/config.py @@ -3,7 +3,7 @@ Everything lives under ~/.v2t (or $V2T_HOME, or $XDG_CONFIG_HOME/v2t): config.toml user settings history/transcriptions.jsonl every transcription + metadata - run/status.json live state for the SwiftBar plugin + run/status.json live state for CLI and menu-bar clients Zero config works: the defaults below are the shipped behaviour (Parakeet + Qwen3, MLX, strict cleanup). @@ -122,8 +122,8 @@ def running_pid() -> int | None: def write_status(data: dict) -> None: - """Atomically write private runtime status for SwiftBar.""" - directory = _private_dir(run_dir()) + """Atomically write private runtime status for CLI and menu-bar clients.""" + directory = run_dir() fd, temp_name = tempfile.mkstemp(dir=directory) try: os.fchmod(fd, 0o600) diff --git a/v2t/menubar.py b/v2t/menubar.py new file mode 100644 index 0000000..6a20722 --- /dev/null +++ b/v2t/menubar.py @@ -0,0 +1,135 @@ +"""Build and launch the optional one-file macOS menu-bar app.""" + +from __future__ import annotations + +import fcntl +import os +import plistlib +import re +import shutil +import subprocess +import sys +import tempfile +from importlib.resources import as_file, files +from pathlib import Path + +from . import __version__, config + +APP_NAME = "Voice2Text.app" +BUNDLE_ID = "com.lucharo.voice2text" + + +def app_path() -> Path: + return Path.home() / "Applications" / APP_NAME + + +def app_executable() -> Path: + return app_path() / "Contents" / "MacOS" / "Voice2Text" + + +def installed() -> bool: + return app_executable().is_file() + + +def running() -> bool: + """Whether the menu app owns its single-instance lock.""" + path = config.run_dir() / "menubar.lock" + if not path.exists(): + return False + with path.open("a+") as handle: + try: + fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + return True + fcntl.flock(handle, fcntl.LOCK_UN) + return False + + +def signing_identity() -> str: + """Use a stable local development identity when one is already available.""" + result = subprocess.run( + ["security", "find-identity", "-v", "-p", "codesigning"], + capture_output=True, + text=True, + ) + identities = re.findall(r'"([^"]+)"', result.stdout) + return next( + (identity for identity in identities if identity.startswith("Apple Development:")), + "-", + ) + + +def install() -> Path: + """Compile the bundled Swift source into a small, grantable app bundle.""" + if sys.platform != "darwin": + raise SystemExit("the Voice2Text menu app is macOS-only") + if running(): + raise SystemExit("quit Voice2Text before updating the menu app") + destination = app_path() + destination.parent.mkdir(parents=True, exist_ok=True) + source = files("v2t").joinpath("native", "Voice2Text.swift") + with as_file(source) as source_path, tempfile.TemporaryDirectory( + dir=destination.parent + ) as temporary: + bundle = Path(temporary) / APP_NAME + contents = bundle / "Contents" + executable = contents / "MacOS" / "Voice2Text" + executable.parent.mkdir(parents=True) + info = { + "CFBundleDevelopmentRegion": "en", + "CFBundleExecutable": "Voice2Text", + "CFBundleIdentifier": BUNDLE_ID, + "CFBundleInfoDictionaryVersion": "6.0", + "CFBundleName": "Voice2Text", + "CFBundlePackageType": "APPL", + "CFBundleShortVersionString": __version__, + "CFBundleVersion": "1", + "LSMinimumSystemVersion": "13.0", + "LSUIElement": True, + "NSMicrophoneUsageDescription": "Voice2Text uses the microphone for fully local transcription.", + "NSPrincipalClass": "NSApplication", + "V2THome": str(config.home()), + "V2TPythonExecutable": sys.executable, + } + if custom_config := os.environ.get("V2T_CONFIG"): + info["V2TConfig"] = str(Path(custom_config).expanduser()) + (contents / "Info.plist").write_bytes(plistlib.dumps(info)) + subprocess.run( + [ + "xcrun", + "swiftc", + "-parse-as-library", + str(source_path), + "-o", + str(executable), + "-framework", + "AppKit", + "-framework", + "AVFoundation", + "-framework", + "ApplicationServices", + ], + check=True, + ) + executable.chmod(0o755) + subprocess.run( + [ + "codesign", + "--force", + "--deep", + "--sign", + signing_identity(), + str(bundle), + ], + check=True, + ) + if destination.exists(): + shutil.rmtree(destination) + shutil.move(bundle, destination) + return destination + + +def open_app() -> None: + if not installed(): + raise SystemExit("menu app is not installed; run: v2t menubar install") + subprocess.run(["open", str(app_path())], check=True) diff --git a/v2t/native/Voice2Text.swift b/v2t/native/Voice2Text.swift new file mode 100644 index 0000000..a12387f --- /dev/null +++ b/v2t/native/Voice2Text.swift @@ -0,0 +1,292 @@ +import AppKit +import ApplicationServices +import AVFoundation + +@main +struct Voice2TextApp { + static func main() { + let app = NSApplication.shared + let delegate = Voice2TextMenu() + app.delegate = delegate + app.setActivationPolicy(.accessory) + app.run() + } +} + +final class Voice2TextMenu: NSObject, NSApplicationDelegate { + private let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + private let menu = NSMenu() + private var engine: Process? + private var timer: Timer? + private var phase = "off" + private var status: [String: Any] = [:] + private var externalEngine = false + private var lockFD: Int32 = -1 + private var logHandle: FileHandle? + private var rendered = "" + + private var home: URL { + let value = Bundle.main.object(forInfoDictionaryKey: "V2THome") as? String + return URL(fileURLWithPath: value ?? NSHomeDirectory() + "/.v2t") + } + + func applicationDidFinishLaunching(_ notification: Notification) { + guard acquireAppLock() else { + NSApp.terminate(nil) + return + } + item.menu = menu + render() + timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in + self?.refresh() + } + if CommandLine.arguments.contains("--start") { + start() + } + } + + func applicationWillTerminate(_ notification: Notification) { + timer?.invalidate() + engine?.terminate() + logHandle?.closeFile() + logHandle = nil + if lockFD >= 0 { close(lockFD) } + } + + private func acquireAppLock() -> Bool { + let run = home.appendingPathComponent("run") + try? FileManager.default.createDirectory(at: run, withIntermediateDirectories: true) + chmod(home.path, 0o700) + chmod(run.path, 0o700) + lockFD = open(run.appendingPathComponent("menubar.lock").path, O_CREAT | O_RDWR, 0o600) + if lockFD >= 0 { fchmod(lockFD, 0o600) } + return lockFD >= 0 && flock(lockFD, LOCK_EX | LOCK_NB) == 0 + } + + @objc private func start() { + guard engine == nil && !externalEngine && phase != "permissions" && phase != "starting" else { return } + NSApp.activate(ignoringOtherApps: true) + try? FileManager.default.removeItem(at: home.appendingPathComponent("run/last-error")) + phase = "permissions" + render() + switch AVCaptureDevice.authorizationStatus(for: .audio) { + case .authorized: + requestSystemPermissions() + case .notDetermined: + AVCaptureDevice.requestAccess(for: .audio) { [weak self] granted in + DispatchQueue.main.async { + if granted { self?.requestSystemPermissions() } + else { self?.phase = "permission-error"; self?.render() } + } + } + default: + phase = "permission-error" + render() + } + } + + private func requestSystemPermissions() { + let prompt = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary + let accessibility = AXIsProcessTrustedWithOptions(prompt) + guard accessibility else { + phase = "permission-error" + render() + return + } + guard CGRequestListenEventAccess() else { + phase = "permission-error" + render() + return + } + launchEngine() + } + + private func launchEngine() { + guard let python = Bundle.main.object(forInfoDictionaryKey: "V2TPythonExecutable") as? String else { + phase = "error" + render() + return + } + let process = Process() + process.executableURL = URL(fileURLWithPath: python) + process.arguments = ["-m", "v2t"] + var environment = ProcessInfo.processInfo.environment + environment["V2T_HOME"] = home.path + environment["V2T_LAUNCH_CONTEXT"] = "menubar" + if let path = Bundle.main.object(forInfoDictionaryKey: "V2TConfig") as? String { + environment["V2T_CONFIG"] = path + } + process.environment = environment + let log = home.appendingPathComponent("run/v2t.log") + if let size = try? log.resourceValues(forKeys: [.fileSizeKey]).fileSize, size > 1_048_576 { + let previous = log.deletingPathExtension().appendingPathExtension("log.1") + try? FileManager.default.removeItem(at: previous) + try? FileManager.default.moveItem(at: log, to: previous) + } + let logFD = open(log.path, O_WRONLY | O_CREAT | O_APPEND, 0o600) + if logFD >= 0 { + fchmod(logFD, 0o600) + let handle = FileHandle(fileDescriptor: logFD, closeOnDealloc: true) + logHandle = handle + process.standardOutput = handle + process.standardError = handle + } + process.terminationHandler = { [weak self] _ in + DispatchQueue.main.async { + self?.engine = nil + self?.logHandle?.closeFile() + self?.logHandle = nil + self?.phase = "off" + self?.refresh() + } + } + do { + phase = "starting" + render() + engine = process + try process.run() + } catch { + engine = nil + phase = "error" + render() + } + } + + @objc private func stop() { + engine?.terminate() + phase = "stopping" + render() + } + + private func refresh() { + let url = home.appendingPathComponent("run/status.json") + var live = false + if let data = try? Data(contentsOf: url), + let value = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + if let pid = value["pid"] as? Int, engineOwnsLock(pid) { + live = true + status = value + externalEngine = engine == nil + phase = value["state"] as? String ?? phase + } + } + if !live && engine == nil && phase != "permission-error" && phase != "error" { + phase = "off" + status = [:] + externalEngine = false + try? FileManager.default.removeItem(at: url) + } + if engine == nil, let message = try? String(contentsOf: home.appendingPathComponent("run/last-error"), encoding: .utf8), !message.isEmpty { + phase = "error" + } + render() + } + + private func engineOwnsLock(_ pid: Int) -> Bool { + let path = home.appendingPathComponent("run/v2t.lock").path + let fd = open(path, O_RDWR) + guard fd >= 0 else { return false } + defer { close(fd) } + if flock(fd, LOCK_EX | LOCK_NB) == 0 { + flock(fd, LOCK_UN) + return false + } + let owner = try? String(contentsOfFile: path, encoding: .utf8) + .trimmingCharacters(in: .whitespacesAndNewlines) + return owner == String(pid) + } + + private func render() { + let microphone = microphoneGranted + let accessibility = AXIsProcessTrusted() + let input = CGPreflightListenEventAccess() + let stt = status["stt"] as? String ?? "" + let cleanup = status["cleanup"] as? String ?? "" + let signature = "\(phase)|\(stt)|\(cleanup)|\(engine != nil)|\(externalEngine)|\(microphone)|\(accessibility)|\(input)" + guard rendered != signature else { return } + rendered = signature + let presentation: (String, String) = switch phase { + case "permissions": ("🎙️🟠", "Checking permissions…") + case "starting", "loading-stt": ("🎙️🟠", "Loading transcription model…") + case "loading-cleanup": ("🎙️🟠", "Loading cleanup model…") + case "idle": ("🎙️🟢", "Ready") + case "recording": ("🎙️🔴", "Recording…") + case "transcribing": ("🎙️🟡", "Transcribing…") + case "cleaning": ("🎙️🟡", "Cleaning up…") + case "stopping": ("🎙️🟠", "Stopping…") + case "permission-error": ("🎙️⚠️", "Permissions required") + case "error": ("🎙️⚠️", "Could not start — open Log") + default: ("🎙️", "Off") + } + item.button?.title = presentation.0 + menu.removeAllItems() + add(presentation.1, enabled: false) + if !stt.isEmpty && !cleanup.isEmpty { + add("\(stt) · \(cleanup == "off" ? "no cleanup" : "clean: \(cleanup)")", enabled: false) + } + menu.addItem(.separator()) + if externalEngine { add("Running from terminal", enabled: false) } + else if phase == "permissions" || phase == "starting" { add("Starting…", enabled: false) } + else if engine == nil { add("Start v2t", action: #selector(start)) } + else { add("Stop v2t", action: #selector(stop)) } + menu.addItem(.separator()) + add(permissionLabel("Microphone", microphone), action: #selector(openMicrophone)) + add(permissionLabel("Accessibility", accessibility), action: #selector(openAccessibility)) + add(permissionLabel("Input Monitoring", input), action: #selector(openInput)) + menu.addItem(.separator()) + add("Config", action: #selector(openConfig)) + add("Transcription History", action: #selector(openHistory)) + add("Log", action: #selector(openLog)) + menu.addItem(.separator()) + add("Quit Voice2Text", action: #selector(quit)) + } + + private var microphoneGranted: Bool { + AVCaptureDevice.authorizationStatus(for: .audio) == .authorized + } + + private func permissionLabel(_ name: String, _ granted: Bool) -> String { + "\(granted ? "✓" : "○") \(name) · \(granted ? "Granted" : "Click to grant")" + } + + private func add(_ title: String, action: Selector? = nil, enabled: Bool = true) { + let row = NSMenuItem(title: title, action: action, keyEquivalent: "") + row.target = self + row.isEnabled = enabled + menu.addItem(row) + } + + private func openPane(_ pane: String) { + let base = "x-apple.systempreferences:com.apple.preference.security?" + if let url = URL(string: base + pane) { NSWorkspace.shared.open(url) } + } + + @objc private func openMicrophone() { + NSApp.activate(ignoringOtherApps: true) + if AVCaptureDevice.authorizationStatus(for: .audio) == .notDetermined { + AVCaptureDevice.requestAccess(for: .audio) { [weak self] _ in + DispatchQueue.main.async { self?.rendered = ""; self?.render() } + } + } else { + openPane("Privacy_Microphone") + } + } + + @objc private func openAccessibility() { + let prompt = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary + _ = AXIsProcessTrustedWithOptions(prompt) + rendered = "" + render() + } + + @objc private func openInput() { + NSApp.activate(ignoringOtherApps: true) + if !CGRequestListenEventAccess() { openPane("Privacy_ListenEvent") } + rendered = "" + render() + } + @objc private func openConfig() { NSWorkspace.shared.open(home) } + @objc private func openHistory() { NSWorkspace.shared.open(home.appendingPathComponent("history")) } + @objc private func openLog() { NSWorkspace.shared.open(home.appendingPathComponent("run/v2t.log")) } + @objc private func quit() { NSApp.terminate(nil) } +} diff --git a/v2t/service.py b/v2t/service.py index ac1358e..596de37 100644 --- a/v2t/service.py +++ b/v2t/service.py @@ -10,9 +10,9 @@ import time from pathlib import Path -from . import config +from . import config, menubar -LABEL = "com.lucharo.voice2text" +LABEL = menubar.BUNDLE_ID def plist_path() -> Path: @@ -51,20 +51,11 @@ def service_pid() -> int | None: def plist_data() -> dict: - environment = { - "PATH": os.environ.get( - "PATH", "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" - ), - "V2T_HOME": str(config.home()), - } - if custom_config := os.environ.get("V2T_CONFIG"): - environment["V2T_CONFIG"] = str(Path(custom_config).expanduser()) log = config.run_dir() / "v2t.log" return { "Label": LABEL, - "ProgramArguments": [sys.executable, "-m", "v2t"], + "ProgramArguments": [str(menubar.app_executable()), "--start"], "RunAtLoad": True, - "EnvironmentVariables": environment, "StandardOutPath": str(log), "StandardErrorPath": str(log), "Umask": 0o077, @@ -74,8 +65,6 @@ def plist_data() -> dict: def _prepare_log() -> None: config.ensure_dirs() log = config.run_dir() / "v2t.log" - if log.exists() and log.stat().st_size > 1_048_576: - log.replace(log.with_suffix(".log.1")) log.touch() log.chmod(0o600) @@ -83,12 +72,16 @@ def _prepare_log() -> None: def install() -> Path: if sys.platform != "darwin": raise SystemExit("the v2t service is macOS-only") - running, managed = config.running_pid(), service_pid() - if running is not None and running != managed: + if not menubar.installed(): + raise SystemExit("menu app is not installed; run: v2t menubar install") + if menubar.running() and service_pid() is None: + raise SystemExit("quit Voice2Text before installing the login service") + if config.running_pid() is not None and service_pid() is None: raise SystemExit( - "v2t is already running directly; stop it before installing the service" + "v2t is already running outside the login service; stop it first" ) if loaded(): + stop() _launchctl("bootout", target()) _prepare_log() path = plist_path() @@ -101,7 +94,7 @@ def install() -> Path: os.replace(temp_name, path) finally: Path(temp_name).unlink(missing_ok=True) - start() + _launchctl("bootstrap", f"gui/{os.getuid()}", str(path)) return path @@ -109,22 +102,22 @@ def start() -> None: path = plist_path() if not path.exists(): raise SystemExit("service is not installed; run: v2t service install") - _prepare_log() - running, managed = config.running_pid(), service_pid() - if running is not None and running == managed: + if service_pid() is not None: return - if running is not None: + if menubar.running(): + raise SystemExit("quit Voice2Text before starting the login service") + if config.running_pid() is not None: raise SystemExit( - "v2t is already running directly; stop it before starting the service" + "v2t is already running outside the login service; stop it first" ) - config.clear_last_error() + _prepare_log() if loaded(): _launchctl("kickstart", target()) else: _launchctl("bootstrap", f"gui/{os.getuid()}", str(path)) deadline = time.monotonic() + 3 while time.monotonic() < deadline: - if config.running_pid() is not None: + if service_pid() is not None: return if error := config.read_last_error(): raise RuntimeError(error) @@ -138,12 +131,8 @@ def stop() -> None: def uninstall() -> None: - running, managed = config.running_pid(), service_pid() - if running is not None and running != managed: - raise SystemExit( - "v2t is running directly; stop it before uninstalling the service" - ) if loaded(): + stop() _launchctl("bootout", target()) plist_path().unlink(missing_ok=True) @@ -151,9 +140,8 @@ def uninstall() -> None: def status() -> str: if not plist_path().exists(): return "not installed" - running, managed = config.running_pid(), service_pid() - if running is not None and running == managed: - return "running" - if running is not None: - return "installed; v2t is running directly" + if service_pid() is not None: + return "running" if config.running_pid() is not None else "menu running; v2t off" + if config.running_pid() is not None: + return "installed; v2t is running outside the login service" return "loaded" if loaded() else "installed but unloaded" From a1bb8ec7c69e44bacec668d3f25ae871275c77df Mon Sep 17 00:00:00 2001 From: lucharo Date: Mon, 20 Jul 2026 21:27:07 +0100 Subject: [PATCH 14/38] fix: remove redundant input monitoring gate --- README.md | 4 ++-- tests/test_smoke.py | 13 +++++-------- v2t/app.py | 1 - v2t/native/Voice2Text.swift | 15 +-------------- v2t/permissions.py | 8 +++----- 5 files changed, 11 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 3889767..b21e7b3 100644 --- a/README.md +++ b/README.md @@ -171,8 +171,8 @@ v2t service uninstall The service starts the same `Voice2Text.app` bundle, so manual and login launches share one stable permission identity. The engine lock still prevents duplicate Python processes. -**Permissions.** v2t needs **Microphone** (record), **Accessibility** + **Input Monitoring** (hotkey -+ paste). A terminal launch uses your terminal's grants. The menu app requests its own grants and +**Permissions.** v2t needs **Microphone** (record) and **Accessibility** (global hotkey + paste). A +terminal launch uses your terminal's grants. The menu app requests its own grants and shows their live state as flat rows; click a missing row to open the exact Settings pane. ## Models & benchmarks diff --git a/tests/test_smoke.py b/tests/test_smoke.py index a01a3e8..7ce8535 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -49,15 +49,14 @@ def test_audio_device_failure_returns_to_error_state(self): ["nowplaying-cli", "pause"], [call.args[0] for call in run.call_args_list] ) - def test_permission_check_reports_each_missing_native_permission(self): + def test_permission_check_reports_missing_accessibility(self): with ( mock.patch.object( app.permissions, "statuses", return_value={ "microphone": "granted", - "accessibility": "granted", - "input": "missing", + "accessibility": "missing", }, ), mock.patch.object(app.subprocess, "run") as run, @@ -68,11 +67,11 @@ def test_permission_check_reports_each_missing_native_permission(self): run.assert_any_call( [ "open", - "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent", + "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility", ], check=False, ) - self.assertIn("Input Monitoring", config.read_last_error()) + self.assertIn("Accessibility", config.read_last_error()) def test_permission_check_requests_microphone_before_startup(self): with ( @@ -82,7 +81,6 @@ def test_permission_check_requests_microphone_before_startup(self): return_value={ "microphone": "not-requested", "accessibility": "granted", - "input": "granted", }, ), mock.patch.object( @@ -96,7 +94,6 @@ def test_permission_check_requests_microphone_before_startup(self): def test_permission_statuses_use_native_macos_checks(self): application_services = types.SimpleNamespace( AXIsProcessTrusted=lambda: True, - CGPreflightListenEventAccess=lambda: False, ) avfoundation = types.SimpleNamespace( AVCaptureDevice=types.SimpleNamespace( @@ -118,7 +115,7 @@ def test_permission_statuses_use_native_macos_checks(self): self.assertEqual( states, - {"microphone": "granted", "accessibility": "granted", "input": "missing"}, + {"microphone": "granted", "accessibility": "granted"}, ) def test_microphone_request_waits_for_native_result(self): diff --git a/v2t/app.py b/v2t/app.py index 36ac5d3..45ca418 100644 --- a/v2t/app.py +++ b/v2t/app.py @@ -58,7 +58,6 @@ def check_and_request_permissions() -> None: states["accessibility"] == "granted", "Privacy_Accessibility", ), - ("Input Monitoring", states["input"] == "granted", "Privacy_ListenEvent"), ] missing = [(name, pane) for name, granted, pane in checks if not granted] if missing: diff --git a/v2t/native/Voice2Text.swift b/v2t/native/Voice2Text.swift index a12387f..84ff449 100644 --- a/v2t/native/Voice2Text.swift +++ b/v2t/native/Voice2Text.swift @@ -93,11 +93,6 @@ final class Voice2TextMenu: NSObject, NSApplicationDelegate { render() return } - guard CGRequestListenEventAccess() else { - phase = "permission-error" - render() - return - } launchEngine() } @@ -199,10 +194,9 @@ final class Voice2TextMenu: NSObject, NSApplicationDelegate { private func render() { let microphone = microphoneGranted let accessibility = AXIsProcessTrusted() - let input = CGPreflightListenEventAccess() let stt = status["stt"] as? String ?? "" let cleanup = status["cleanup"] as? String ?? "" - let signature = "\(phase)|\(stt)|\(cleanup)|\(engine != nil)|\(externalEngine)|\(microphone)|\(accessibility)|\(input)" + let signature = "\(phase)|\(stt)|\(cleanup)|\(engine != nil)|\(externalEngine)|\(microphone)|\(accessibility)" guard rendered != signature else { return } rendered = signature let presentation: (String, String) = switch phase { @@ -232,7 +226,6 @@ final class Voice2TextMenu: NSObject, NSApplicationDelegate { menu.addItem(.separator()) add(permissionLabel("Microphone", microphone), action: #selector(openMicrophone)) add(permissionLabel("Accessibility", accessibility), action: #selector(openAccessibility)) - add(permissionLabel("Input Monitoring", input), action: #selector(openInput)) menu.addItem(.separator()) add("Config", action: #selector(openConfig)) add("Transcription History", action: #selector(openHistory)) @@ -279,12 +272,6 @@ final class Voice2TextMenu: NSObject, NSApplicationDelegate { render() } - @objc private func openInput() { - NSApp.activate(ignoringOtherApps: true) - if !CGRequestListenEventAccess() { openPane("Privacy_ListenEvent") } - rendered = "" - render() - } @objc private func openConfig() { NSWorkspace.shared.open(home) } @objc private func openHistory() { NSWorkspace.shared.open(home.appendingPathComponent("history")) } @objc private func openLog() { NSWorkspace.shared.open(home.appendingPathComponent("run/v2t.log")) } diff --git a/v2t/permissions.py b/v2t/permissions.py index e8d2aca..1d4e70b 100644 --- a/v2t/permissions.py +++ b/v2t/permissions.py @@ -1,4 +1,4 @@ -"""Read the three macOS privacy grants v2t needs, without prompting.""" +"""Read the two macOS privacy grants v2t needs, without prompting.""" from __future__ import annotations @@ -8,12 +8,11 @@ def statuses() -> dict[str, str]: if sys.platform != "darwin": - return {"microphone": "unknown", "accessibility": "unknown", "input": "unknown"} + return {"microphone": "unknown", "accessibility": "unknown"} - from ApplicationServices import AXIsProcessTrusted, CGPreflightListenEventAccess + from ApplicationServices import AXIsProcessTrusted accessibility = "granted" if AXIsProcessTrusted() else "missing" - input_monitoring = "granted" if CGPreflightListenEventAccess() else "missing" try: from AVFoundation import AVCaptureDevice, AVMediaTypeAudio @@ -31,7 +30,6 @@ def statuses() -> dict[str, str]: return { "microphone": microphone, "accessibility": accessibility, - "input": input_monitoring, } From 722bfe6e2cbc6085e4150a5f2bce40f67138bbc8 Mon Sep 17 00:00:00 2001 From: lucharo Date: Mon, 20 Jul 2026 21:37:07 +0100 Subject: [PATCH 15/38] fix: harden native service startup --- tests/test_smoke.py | 12 ++++++++++++ v2t/native/Voice2Text.swift | 3 +++ v2t/service.py | 10 ++++++++-- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 7ce8535..ffe54a0 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -302,6 +302,7 @@ def compile_app(command, **_kwargs): with ( mock.patch.object(menubar, "app_path", return_value=destination), + mock.patch.object(menubar.sys, "platform", "darwin"), mock.patch.object(menubar, "signing_identity", return_value="-"), mock.patch.object(menubar.subprocess, "run", side_effect=compile_app), mock.patch.dict( @@ -397,6 +398,17 @@ def test_service_start_does_not_restart_a_healthy_process(self): launchctl.assert_not_called() + def test_service_start_rejects_a_menu_with_no_engine(self): + plist = Path(self.tempdir.name) / "com.lucharo.voice2text.plist" + plist.touch() + with ( + mock.patch.object(service, "plist_path", return_value=plist), + mock.patch.object(service, "service_pid", return_value=42), + mock.patch.object(config, "running_pid", return_value=None), + self.assertRaisesRegex(RuntimeError, "engine is off"), + ): + service.start() + def test_cleanup_refuses_to_return_a_capped_partial_result(self): cleaner = object.__new__(backends.MLXCleanup) cleaner.model = object() diff --git a/v2t/native/Voice2Text.swift b/v2t/native/Voice2Text.swift index 84ff449..9300cda 100644 --- a/v2t/native/Voice2Text.swift +++ b/v2t/native/Voice2Text.swift @@ -108,6 +108,9 @@ final class Voice2TextMenu: NSObject, NSApplicationDelegate { var environment = ProcessInfo.processInfo.environment environment["V2T_HOME"] = home.path environment["V2T_LAUNCH_CONTEXT"] = "menubar" + let toolBin = URL(fileURLWithPath: python).deletingLastPathComponent().path + let inheritedPath = environment["PATH"] ?? "/usr/bin:/bin" + environment["PATH"] = "\(toolBin):/opt/homebrew/bin:/usr/local/bin:\(inheritedPath)" if let path = Bundle.main.object(forInfoDictionaryKey: "V2TConfig") as? String { environment["V2T_CONFIG"] = path } diff --git a/v2t/service.py b/v2t/service.py index 596de37..ef6c0f7 100644 --- a/v2t/service.py +++ b/v2t/service.py @@ -103,7 +103,11 @@ def start() -> None: if not path.exists(): raise SystemExit("service is not installed; run: v2t service install") if service_pid() is not None: - return + if config.running_pid() is not None: + return + if error := config.read_last_error(): + raise RuntimeError(error) + raise RuntimeError("Voice2Text menu is running but the v2t engine is off") if menubar.running(): raise SystemExit("quit Voice2Text before starting the login service") if config.running_pid() is not None: @@ -117,11 +121,13 @@ def start() -> None: _launchctl("bootstrap", f"gui/{os.getuid()}", str(path)) deadline = time.monotonic() + 3 while time.monotonic() < deadline: - if service_pid() is not None: + if config.running_pid() is not None: return if error := config.read_last_error(): raise RuntimeError(error) time.sleep(0.1) + if service_pid() is not None: + raise RuntimeError("Voice2Text menu started but the v2t engine is off") raise RuntimeError(f"service did not start; check {config.run_dir() / 'v2t.log'}") From 183f287c215d691ebb0db02696d74ce06695488d Mon Sep 17 00:00:00 2001 From: lucharo Date: Mon, 20 Jul 2026 21:59:45 +0100 Subject: [PATCH 16/38] fix: keep MLX inference on its loading thread --- tests/test_smoke.py | 18 +++++++++++++----- v2t/app.py | 30 +++++++++++++++--------------- 2 files changed, 28 insertions(+), 20 deletions(-) diff --git a/tests/test_smoke.py b/tests/test_smoke.py index ffe54a0..0318812 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -156,18 +156,26 @@ def test_recording_cannot_restart_before_processing_begins(self): voice.frames = [np.ones((2, 1), dtype=np.float32)] voice.stream = mock.Mock() - with ( - mock.patch.object(app.threading, "Thread") as thread, - mock.patch.object(app.sd, "InputStream") as input_stream, - ): + with mock.patch.object(app.sd, "InputStream") as input_stream: voice.stop_recording() voice.start_recording() self.assertTrue(voice.processing) self.assertEqual(voice.frames, []) - thread.return_value.start.assert_called_once_with() + frames, duration = voice.jobs.get_nowait() + self.assertEqual(len(frames), 1) + self.assertGreaterEqual(duration, 0) input_stream.assert_not_called() + def test_queued_transcription_runs_on_the_processing_thread(self): + voice = app.VoiceToText(config.Config(cleanup_enabled=False)) + voice.jobs.put(([np.ones((8, 1), dtype=np.float32)], 1.0)) + + with mock.patch.object(voice, "process_audio") as process: + self.assertTrue(voice.process_next()) + + process.assert_called_once() + def test_transcription_pipeline_pastes_cleanup_and_deletes_audio(self): voice = app.VoiceToText(config.Config(save_history=False)) audio_path = None diff --git a/v2t/app.py b/v2t/app.py index 45ca418..c7ed011 100644 --- a/v2t/app.py +++ b/v2t/app.py @@ -6,11 +6,11 @@ from __future__ import annotations import os +import queue import signal import subprocess import sys import tempfile -import threading import time from pathlib import Path @@ -85,7 +85,7 @@ def __init__(self, cfg: Config): self.record_start = 0.0 self.was_playing = False self._warned_mic = False - self.worker = None + self.jobs = queue.Queue() self.instance_lock = None self.stopping = False cleanup_model = ( @@ -187,10 +187,7 @@ def stop_recording(self): if self.frames: frames, self.frames = self.frames, [] self.processing = True - self.worker = threading.Thread( - target=self.process_audio, args=(frames, duration) - ) - self.worker.start() + self.jobs.put((frames, duration)) else: self._restore_media() self._set_state("idle") @@ -279,6 +276,14 @@ def process_audio(self, frames: list[np.ndarray], audio_s: float): self._set_state(next_state, error_message) self.processing = False + def process_next(self) -> bool: + """Process one queued recording on the model-owning thread.""" + job = self.jobs.get() + if job is None: + return False + self.process_audio(*job) + return True + def paste_to_cursor(self, text: str) -> None: """Paste at the cursor, preserving every native pasteboard representation.""" from AppKit import NSPasteboard, NSPasteboardItem, NSPasteboardTypeString @@ -388,8 +393,9 @@ def run(self): ) with keyboard.Listener( on_press=self.on_press, on_release=self.on_release - ) as listener: - listener.join() + ): + while self.process_next(): + pass except KeyboardInterrupt: logger.info("Shutting down...") except SystemExit as error: @@ -411,15 +417,9 @@ def shutdown(self) -> None: return self.stopping = True self.recording = False + self.jobs.put(None) self._close_stream() self._restore_media() - if ( - self.worker is not None - and self.worker.is_alive() - and self.worker is not threading.current_thread() - ): - self._set_state("stopping") - self.worker.join() self._clear_status() if self.instance_lock is not None: self.instance_lock.close() From 121463fb4363b30095a39718a211ca03363e04a3 Mon Sep 17 00:00:00 2001 From: lucharo Date: Mon, 20 Jul 2026 22:11:22 +0100 Subject: [PATCH 17/38] perf: skip Hub validation for cached Parakeet --- tests/test_smoke.py | 43 +++++++++++++++++++++++++++++++++++++++++++ v2t/backends.py | 15 ++++++++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 0318812..19e8661 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -288,6 +288,49 @@ def test_missing_optional_stt_is_rendered_as_not_available(self): self.assertIn("| load | n/a |", table) self.assertIn("| short (1.0s) | n/a |", table) + def test_parakeet_prefers_cached_weights_without_hub_validation(self): + cached = "/cache/parakeet" + load = mock.Mock(return_value=mock.Mock()) + snapshot_download = mock.Mock(return_value=cached) + hub_error = type("LocalEntryNotFoundError", (Exception,), {}) + + with mock.patch.dict( + sys.modules, + { + "parakeet_mlx": types.SimpleNamespace(from_pretrained=load), + "huggingface_hub": types.SimpleNamespace( + snapshot_download=snapshot_download + ), + "huggingface_hub.errors": types.SimpleNamespace( + LocalEntryNotFoundError=hub_error + ), + }, + ): + backends.ParakeetSTT() + + snapshot_download.side_effect = hub_error() + backends.ParakeetSTT("owner/not-cached") + + self.assertEqual( + snapshot_download.call_args_list, + [ + mock.call( + backends.PARAKEET_DEFAULT, + allow_patterns=["config.json", "model.safetensors"], + local_files_only=True, + ), + mock.call( + "owner/not-cached", + allow_patterns=["config.json", "model.safetensors"], + local_files_only=True, + ), + ], + ) + self.assertEqual( + load.call_args_list, + [mock.call(cached), mock.call("owner/not-cached")], + ) + def test_setup_recommends_the_real_parakeet_install(self): output = io.StringIO() with ( diff --git a/v2t/backends.py b/v2t/backends.py index bff83c3..9f8db70 100644 --- a/v2t/backends.py +++ b/v2t/backends.py @@ -10,6 +10,7 @@ import json import time import urllib.request +from pathlib import Path PARAKEET_DEFAULT = "mlx-community/parakeet-tdt-0.6b-v3" WHISPER_DEFAULT = "mlx-community/whisper-large-v3-turbo" @@ -21,11 +22,23 @@ class ParakeetSTT: def __init__(self, model: str = ""): try: from parakeet_mlx import from_pretrained + from huggingface_hub import snapshot_download + from huggingface_hub.errors import LocalEntryNotFoundError except ImportError as e: raise SystemExit( "parakeet-mlx missing — reinstall voice2text (Apple Silicon only)." ) from e - self.model = from_pretrained(model or self.default_model) + source = model or self.default_model + if not Path(source).exists(): + try: + source = snapshot_download( + source, + allow_patterns=["config.json", "model.safetensors"], + local_files_only=True, + ) + except LocalEntryNotFoundError: + pass # first use: let Parakeet download the model normally + self.model = from_pretrained(source) def transcribe(self, wav_path: str) -> str: return self.model.transcribe(wav_path).text.strip() From d50be72f2167fb079eb17772566059c04ed41a01 Mon Sep 17 00:00:00 2001 From: lucharo Date: Mon, 20 Jul 2026 22:18:37 +0100 Subject: [PATCH 18/38] Revert "perf: skip Hub validation for cached Parakeet" This reverts commit 121463fb4363b30095a39718a211ca03363e04a3. --- tests/test_smoke.py | 43 ------------------------------------------- v2t/backends.py | 15 +-------------- 2 files changed, 1 insertion(+), 57 deletions(-) diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 19e8661..0318812 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -288,49 +288,6 @@ def test_missing_optional_stt_is_rendered_as_not_available(self): self.assertIn("| load | n/a |", table) self.assertIn("| short (1.0s) | n/a |", table) - def test_parakeet_prefers_cached_weights_without_hub_validation(self): - cached = "/cache/parakeet" - load = mock.Mock(return_value=mock.Mock()) - snapshot_download = mock.Mock(return_value=cached) - hub_error = type("LocalEntryNotFoundError", (Exception,), {}) - - with mock.patch.dict( - sys.modules, - { - "parakeet_mlx": types.SimpleNamespace(from_pretrained=load), - "huggingface_hub": types.SimpleNamespace( - snapshot_download=snapshot_download - ), - "huggingface_hub.errors": types.SimpleNamespace( - LocalEntryNotFoundError=hub_error - ), - }, - ): - backends.ParakeetSTT() - - snapshot_download.side_effect = hub_error() - backends.ParakeetSTT("owner/not-cached") - - self.assertEqual( - snapshot_download.call_args_list, - [ - mock.call( - backends.PARAKEET_DEFAULT, - allow_patterns=["config.json", "model.safetensors"], - local_files_only=True, - ), - mock.call( - "owner/not-cached", - allow_patterns=["config.json", "model.safetensors"], - local_files_only=True, - ), - ], - ) - self.assertEqual( - load.call_args_list, - [mock.call(cached), mock.call("owner/not-cached")], - ) - def test_setup_recommends_the_real_parakeet_install(self): output = io.StringIO() with ( diff --git a/v2t/backends.py b/v2t/backends.py index 9f8db70..bff83c3 100644 --- a/v2t/backends.py +++ b/v2t/backends.py @@ -10,7 +10,6 @@ import json import time import urllib.request -from pathlib import Path PARAKEET_DEFAULT = "mlx-community/parakeet-tdt-0.6b-v3" WHISPER_DEFAULT = "mlx-community/whisper-large-v3-turbo" @@ -22,23 +21,11 @@ class ParakeetSTT: def __init__(self, model: str = ""): try: from parakeet_mlx import from_pretrained - from huggingface_hub import snapshot_download - from huggingface_hub.errors import LocalEntryNotFoundError except ImportError as e: raise SystemExit( "parakeet-mlx missing — reinstall voice2text (Apple Silicon only)." ) from e - source = model or self.default_model - if not Path(source).exists(): - try: - source = snapshot_download( - source, - allow_patterns=["config.json", "model.safetensors"], - local_files_only=True, - ) - except LocalEntryNotFoundError: - pass # first use: let Parakeet download the model normally - self.model = from_pretrained(source) + self.model = from_pretrained(model or self.default_model) def transcribe(self, wav_path: str) -> str: return self.model.transcribe(wav_path).text.strip() From e45f700dc57ae2c0946549805828d938a0f04045 Mon Sep 17 00:00:00 2001 From: lucharo Date: Wed, 22 Jul 2026 11:33:49 +0100 Subject: [PATCH 19/38] perf: reduce local dictation memory --- CHANGELOG.md | 4 +++- README.md | 6 +++--- benchmarks/README.md | 5 +++-- v2t/app.py | 5 ++++- v2t/backends.py | 8 ++++++-- v2t/bench.py | 4 ++-- v2t/config.py | 2 +- v2t/native/Voice2Text.swift | 30 ++++++++++++++++++------------ 8 files changed, 40 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d0ae7a..c3fcd13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,9 @@ This release turns v2t into a small, modular, MLX-first package. - **Parakeet is the default backend** (`mlx-community/parakeet-tdt-0.6b-v3`) — ~10× faster than Whisper on Apple Silicon, multilingual. Ships in core, so `uv tool install voice2text` just works. - **Pluggable STT backends** — Whisper is an optional alternative: `uv tool install 'voice2text[whisper]'`, then select it in config. -- **Pluggable cleanup engines** — default is **mlx-lm, in-process** (`Qwen3-4B-Instruct-2507`): no Ollama, no daemon, same MLX stack as transcription. Ollama stays as an optional `engine = "ollama"`. Use a non-thinking model with either. +- **Pluggable cleanup engines** — default is **mlx-lm, in-process** (`Qwen2.5-1.5B-Instruct`): no Ollama, no daemon, same MLX stack as transcription. Ollama stays as an optional `engine = "ollama"`. Use a non-thinking model with either. +- **Lower idle memory and faster cleanup** — the default cleanup model drops from 4B to the locally validated 1.5B model, and paste timing now appears in logs/history. +- **Native menu icon** — the menu uses adaptive SF Symbols instead of emoji for off, loading, ready, recording, processing, and error states. - **Guided `v2t setup`** — pick the transcription model and cleanup engine; detects Ollama and offers it, otherwise defaults to mlx-lm. Writes `~/.v2t/config.toml`. - **Config file** at `~/.v2t/config.toml` (honors `$V2T_HOME` / `$XDG_CONFIG_HOME`), with `v2t config [--init]`. - **Transcription history** — every result + metadata appended to `~/.v2t/history/transcriptions.jsonl`. diff --git a/README.md b/README.md index b21e7b3..acc617b 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ enough now that the basics fit in a small Python package on consumer hardware. - **Push-to-talk** — hold Right ⌘ (configurable), release to transcribe + paste. - **Parakeet (MLX)** transcription by default — ~10× faster than Whisper on Apple Silicon, English + 24 European languages. Whisper stays available as a fallback for rare languages/accents. -- **In-process LLM cleanup** via mlx-lm (`Qwen3-4B-Instruct-2507`) — fixes punctuation, removes fillers. No Ollama, no daemon. Strict or casual. (Ollama optional — see [Cleanup engine](#cleanup-engine).) +- **In-process LLM cleanup** via mlx-lm (`Qwen2.5-1.5B-Instruct`) — fixes punctuation, removes fillers while using about 1.3 GB less model memory than the previous 4B default. No Ollama, no daemon. Strict or casual. (Ollama optional — see [Cleanup engine](#cleanup-engine).) - **Pastes at cursor**, restoring your previous clipboard. - **One config file** at `~/.v2t/config.toml`, plus a JSONL **history** of every transcription. - **Optional one-file Swift menu** — native permissions, instant state, and quick links; no window or Xcode project. @@ -133,7 +133,7 @@ save_history = true ### Cleanup engine Cleanup runs **in-process via [mlx-lm](https://github.com/ml-explore/mlx-lm)** by default -(`Qwen3-4B-Instruct-2507`, non-thinking) — no daemon, no HTTP, same MLX stack as transcription. +(`Qwen2.5-1.5B-Instruct`, non-thinking) — no daemon, no HTTP, same MLX stack as transcription. Already running **[Ollama](https://ollama.com)**? Switch to it (`v2t setup` offers this when it detects Ollama, or edit the config): @@ -184,7 +184,7 @@ not the installed CLI. See [`benchmarks/`](benchmarks/) for the method and defau | | default | why | |---|---|---| | transcription | `parakeet-tdt-0.6b-v3` | fastest on Apple Silicon, multilingual | -| cleanup | `Qwen3-4B-Instruct-2507` (mlx-lm) | latest small instruct, non-thinking, no daemon | +| cleanup | `Qwen2.5-1.5B-Instruct` (mlx-lm) | 831 MB active in the local cleanup benchmark; non-thinking, no daemon | > This is **macOS / Apple Silicon-only** by design (MLX, native pasteboard/event APIs, > `nowplaying-cli`, System Settings permission URLs). Fork it for Linux/Windows if you like. diff --git a/benchmarks/README.md b/benchmarks/README.md index 32158f9..7c5d45d 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -26,6 +26,7 @@ just bench --stt-models \ whisper:mlx-community/whisper-large-v3-turbo just bench --cleanup-models \ + mlx:mlx-community/Qwen2.5-1.5B-Instruct-4bit \ mlx:mlx-community/Qwen3-4B-Instruct-2507-4bit \ ollama:qwen3:4b-instruct-2507 ``` @@ -42,8 +43,8 @@ to the grid. | STT (default) | `mlx-community/parakeet-tdt-0.6b-v3` | multilingual (EN/ES/FR/DE…), fastest on Apple Silicon | | STT (alt) | `mlx-community/parakeet-tdt-0.6b-v2` | English-only, slightly higher EN accuracy | | STT (fallback) | `mlx-community/whisper-large-v3-turbo` | the old default; best for rare languages/accents | -| cleanup (default) | `mlx:mlx-community/Qwen3-4B-Instruct-2507-4bit` | in-process, non-thinking, no daemon | -| cleanup (alt) | `mlx:mlx-community/Qwen2.5-3B-Instruct-4bit` | smaller, non-thinking | +| cleanup (default) | `mlx:mlx-community/Qwen2.5-1.5B-Instruct-4bit` | in-process, non-thinking; 831 MB active in the local benchmark | +| cleanup (quality alt) | `mlx:mlx-community/Qwen3-4B-Instruct-2507-4bit` | 4B previous default; higher memory | | cleanup (ollama) | `ollama:qwen3:4b-instruct-2507` | if you already run Ollama | > **Use a non-thinking model.** Hybrid Qwen3 tags emit `` blocks that diff --git a/v2t/app.py b/v2t/app.py index c7ed011..c68f2a7 100644 --- a/v2t/app.py +++ b/v2t/app.py @@ -240,8 +240,10 @@ def process_audio(self, frames: list[np.ndarray], audio_s: float): logger.warning("Falling back to raw transcription") cleaned_text = raw_text + t0 = time.perf_counter() self.paste_to_cursor(cleaned_text) - logger.success("Pasted!") + paste_s = time.perf_counter() - t0 + logger.success(f"Pasted ({paste_s:.2f}s including clipboard restore)") if self.cfg.save_history: try: @@ -259,6 +261,7 @@ def process_audio(self, frames: list[np.ndarray], audio_s: float): "mode": self.cfg.mode, "stt_s": round(stt_s, 3), "cleanup_s": round(cleanup_s, 3), + "paste_s": round(paste_s, 3), "raw": raw_text, "clean": cleaned_text, } diff --git a/v2t/backends.py b/v2t/backends.py index bff83c3..63f6770 100644 --- a/v2t/backends.py +++ b/v2t/backends.py @@ -73,7 +73,7 @@ def make_stt(backend: str, model: str = ""): ), } -MLX_CLEANUP_DEFAULT = "mlx-community/Qwen3-4B-Instruct-2507-4bit" +MLX_CLEANUP_DEFAULT = "mlx-community/Qwen2.5-1.5B-Instruct-4bit" OLLAMA_CLEANUP_DEFAULT = "qwen3:4b-instruct-2507" @@ -166,6 +166,7 @@ def make_cleanup(engine: str, model: str = "", url: str = "http://localhost:1143 "parakeet-tdt-0.6b-v3": "parakeet-v3", "parakeet-tdt-0.6b-v2": "parakeet-v2", "whisper-large-v3-turbo": "whisper-turbo", + "Qwen2.5-1.5B-Instruct-4bit": "Qwen2.5-1.5B", "Qwen3-4B-Instruct-2507-4bit": "Qwen3-4B", "Qwen2.5-3B-Instruct-4bit": "Qwen2.5-3B", "qwen3:4b-instruct-2507": "qwen3:4b", @@ -183,7 +184,10 @@ def short_model(name: str) -> str: assert set(STT) == {"parakeet", "whisper"} assert set(CLEANUP) == {"mlx", "ollama"} assert short_model("mlx-community/parakeet-tdt-0.6b-v3") == "parakeet-v3" - assert short_model("mlx-community/Qwen3-4B-Instruct-2507-4bit") == "Qwen3-4B" + assert ( + short_model("mlx-community/Qwen2.5-1.5B-Instruct-4bit") + == "Qwen2.5-1.5B" + ) assert short_model("custom/unknown") == "unknown" assert ( "filler" in PROMPTS["strict"] diff --git a/v2t/bench.py b/v2t/bench.py index 89dc279..720febd 100644 --- a/v2t/bench.py +++ b/v2t/bench.py @@ -7,7 +7,7 @@ v2t bench # both tables, default models, samples via `say` v2t bench --cleanup # cleanup table only - v2t bench --cleanup-models mlx:mlx-community/Qwen3-4B-Instruct-2507-4bit ollama:qwen3:4b-instruct-2507 + v2t bench --cleanup-models mlx:mlx-community/Qwen2.5-1.5B-Instruct-4bit mlx:mlx-community/Qwen3-4B-Instruct-2507-4bit """ from __future__ import annotations @@ -30,8 +30,8 @@ "whisper:mlx-community/whisper-large-v3-turbo", ] CLEANUP_MODELS = [ + "mlx:mlx-community/Qwen2.5-1.5B-Instruct-4bit", "mlx:mlx-community/Qwen3-4B-Instruct-2507-4bit", - "mlx:mlx-community/Qwen2.5-3B-Instruct-4bit", ] # (name, text) — `say` turns these into audio so the STT bench is self-contained. diff --git a/v2t/config.py b/v2t/config.py index dcaa53f..bdcb914 100644 --- a/v2t/config.py +++ b/v2t/config.py @@ -246,7 +246,7 @@ def _validate(cfg: Config) -> None: [cleanup] enabled = true engine = "mlx" # mlx (in-process via mlx-lm, default) | ollama -model = "" # blank = engine default (Qwen3-4B-Instruct-2507-4bit / qwen3:4b-instruct-2507) +model = "" # blank = engine default (Qwen2.5-1.5B-Instruct-4bit / qwen3:4b-instruct-2507) mode = "strict" # strict (restructures) | casual (punctuation + fillers only) [hotkey] diff --git a/v2t/native/Voice2Text.swift b/v2t/native/Voice2Text.swift index 9300cda..dd71929 100644 --- a/v2t/native/Voice2Text.swift +++ b/v2t/native/Voice2Text.swift @@ -203,19 +203,25 @@ final class Voice2TextMenu: NSObject, NSApplicationDelegate { guard rendered != signature else { return } rendered = signature let presentation: (String, String) = switch phase { - case "permissions": ("🎙️🟠", "Checking permissions…") - case "starting", "loading-stt": ("🎙️🟠", "Loading transcription model…") - case "loading-cleanup": ("🎙️🟠", "Loading cleanup model…") - case "idle": ("🎙️🟢", "Ready") - case "recording": ("🎙️🔴", "Recording…") - case "transcribing": ("🎙️🟡", "Transcribing…") - case "cleaning": ("🎙️🟡", "Cleaning up…") - case "stopping": ("🎙️🟠", "Stopping…") - case "permission-error": ("🎙️⚠️", "Permissions required") - case "error": ("🎙️⚠️", "Could not start — open Log") - default: ("🎙️", "Off") + case "permissions": ("hourglass", "Checking permissions…") + case "starting", "loading-stt": ("hourglass", "Loading transcription model…") + case "loading-cleanup": ("hourglass", "Loading cleanup model…") + case "idle": ("waveform", "Ready") + case "recording": ("waveform.circle.fill", "Recording…") + case "transcribing": ("ellipsis.circle", "Transcribing…") + case "cleaning": ("ellipsis.circle", "Cleaning up…") + case "stopping": ("hourglass", "Stopping…") + case "permission-error": ("exclamationmark.triangle", "Permissions required") + case "error": ("exclamationmark.triangle", "Could not start — open Log") + default: ("waveform.slash", "Off") } - item.button?.title = presentation.0 + let icon = NSImage(systemSymbolName: presentation.0, accessibilityDescription: presentation.1) + ?? NSImage(systemSymbolName: "waveform", accessibilityDescription: presentation.1) + icon?.isTemplate = true + item.button?.image = icon + item.button?.imagePosition = .imageOnly + item.button?.title = "" + item.button?.toolTip = presentation.1 menu.removeAllItems() add(presentation.1, enabled: false) if !stt.isEmpty && !cleanup.isEmpty { From 55750376552d7e93e699ee7ca18d07e8d4b1d1d9 Mon Sep 17 00:00:00 2001 From: lucharo Date: Thu, 23 Jul 2026 14:39:54 +0200 Subject: [PATCH 20/38] ci: install PortAudio for Linux smoke tests --- .github/workflows/check.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 7d5b0da..befa632 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -12,6 +12,7 @@ jobs: - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: python-version: "3.12" + - run: sudo apt-get install -y libportaudio2 - run: uv lock --check - run: uvx ruff check v2t tests - name: Mechanics smoke tests From 09006291842e648575d8e94d015274062d9aca96 Mon Sep 17 00:00:00 2001 From: lucharo Date: Thu, 23 Jul 2026 17:05:13 +0200 Subject: [PATCH 21/38] fix: make shutdown and startup readiness honest --- justfile | 2 +- tests/test_smoke.py | 42 +++++++++++++++++++++++++++++++++++-- v2t/app.py | 22 ++++++++++++------- v2t/service.py | 51 +++++++++++++++++++++++++++++++-------------- 4 files changed, 90 insertions(+), 27 deletions(-) diff --git a/justfile b/justfile index c363b12..1304fe4 100644 --- a/justfile +++ b/justfile @@ -1,6 +1,6 @@ # Lint with ruff lint: - uv run ruff check --fix v2t/ tests/ + uv run ruff check v2t/ tests/ # Fast checks; no models, microphone, or permissions needed check: lint diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 0318812..beb57ea 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -176,6 +176,25 @@ def test_queued_transcription_runs_on_the_processing_thread(self): process.assert_called_once() + def test_empty_job_poll_keeps_the_hotkey_loop_alive(self): + voice = app.VoiceToText(config.Config(cleanup_enabled=False)) + + self.assertTrue(voice.process_next(timeout=0)) + + def test_signal_waits_for_the_active_transcription_before_cleanup(self): + voice = app.VoiceToText(config.Config(cleanup_enabled=False)) + voice.instance_lock = config.acquire_instance_lock() + voice.processing = True + + voice._handle_signal() + + self.assertTrue(voice.stopping) + self.assertEqual(config.running_pid(), os.getpid()) + self.assertIsNone(voice.jobs.get_nowait()) + + voice.shutdown() + self.assertIsNone(config.running_pid()) + def test_transcription_pipeline_pastes_cleanup_and_deletes_audio(self): voice = app.VoiceToText(config.Config(save_history=False)) audio_path = None @@ -399,7 +418,9 @@ def test_service_start_does_not_restart_a_healthy_process(self): with ( mock.patch.object(service, "plist_path", return_value=plist), mock.patch.object(service, "service_pid", return_value=42), - mock.patch.object(config, "running_pid", return_value=42), + mock.patch.object( + config, "read_status", return_value={"pid": 42, "state": "idle"} + ), mock.patch.object(service, "_launchctl") as launchctl, ): service.start() @@ -412,11 +433,28 @@ def test_service_start_rejects_a_menu_with_no_engine(self): with ( mock.patch.object(service, "plist_path", return_value=plist), mock.patch.object(service, "service_pid", return_value=42), - mock.patch.object(config, "running_pid", return_value=None), + mock.patch.object(config, "read_status", return_value=None), + mock.patch.object(config, "read_last_error", return_value="engine is off"), self.assertRaisesRegex(RuntimeError, "engine is off"), ): service.start() + def test_service_start_waits_until_models_are_ready(self): + plist = Path(self.tempdir.name) / "com.lucharo.voice2text.plist" + plist.touch() + statuses = [ + {"pid": 42, "state": "loading-stt"}, + {"pid": 42, "state": "idle"}, + ] + with ( + mock.patch.object(service, "plist_path", return_value=plist), + mock.patch.object(service, "service_pid", return_value=42), + mock.patch.object(config, "read_status", side_effect=statuses) as status, + ): + service.start() + + self.assertEqual(status.call_count, 2) + def test_cleanup_refuses_to_return_a_capped_partial_result(self): cleaner = object.__new__(backends.MLXCleanup) cleaner.model = object() diff --git a/v2t/app.py b/v2t/app.py index c68f2a7..7f5a440 100644 --- a/v2t/app.py +++ b/v2t/app.py @@ -279,9 +279,12 @@ def process_audio(self, frames: list[np.ndarray], audio_s: float): self._set_state(next_state, error_message) self.processing = False - def process_next(self) -> bool: + def process_next(self, timeout: float | None = None) -> bool: """Process one queued recording on the model-owning thread.""" - job = self.jobs.get() + try: + job = self.jobs.get(timeout=timeout) + except queue.Empty: + return True if job is None: return False self.process_audio(*job) @@ -385,6 +388,7 @@ def run(self): config.ensure_dirs() self.hotkey = _resolve_hotkey(self.cfg.hotkey) signal.signal(signal.SIGTERM, self._handle_signal) + signal.signal(signal.SIGINT, self._handle_signal) try: self.warmup() self._set_state("idle") @@ -396,9 +400,11 @@ def run(self): ) with keyboard.Listener( on_press=self.on_press, on_release=self.on_release - ): - while self.process_next(): + ) as listener: + while listener.is_alive() and self.process_next(timeout=0.25): pass + if not self.stopping and not listener.is_alive(): + raise RuntimeError("global hotkey listener stopped unexpectedly") except KeyboardInterrupt: logger.info("Shutting down...") except SystemExit as error: @@ -412,17 +418,17 @@ def run(self): self.shutdown() def _handle_signal(self, *_): - self.shutdown() - raise SystemExit(0) - - def shutdown(self) -> None: if self.stopping: return + logger.info("Finishing the active transcription before shutdown...") self.stopping = True self.recording = False self.jobs.put(None) self._close_stream() self._restore_media() + + def shutdown(self) -> None: + self._handle_signal() self._clear_status() if self.instance_lock is not None: self.instance_lock.close() diff --git a/v2t/service.py b/v2t/service.py index ef6c0f7..87c83d3 100644 --- a/v2t/service.py +++ b/v2t/service.py @@ -13,6 +13,8 @@ from . import config, menubar LABEL = menubar.BUNDLE_ID +READY_STATES = {"idle", "recording", "transcribing", "cleaning"} +START_TIMEOUT = 120 def plist_path() -> Path: @@ -50,6 +52,11 @@ def service_pid() -> int | None: return None +def engine_ready() -> bool: + status = config.read_status() + return bool(status and status.get("state") in READY_STATES) + + def plist_data() -> dict: log = config.run_dir() / "v2t.log" return { @@ -102,33 +109,45 @@ def start() -> None: path = plist_path() if not path.exists(): raise SystemExit("service is not installed; run: v2t service install") - if service_pid() is not None: - if config.running_pid() is not None: + menu_pid = service_pid() + if menu_pid is not None: + if engine_ready(): return - if error := config.read_last_error(): - raise RuntimeError(error) - raise RuntimeError("Voice2Text menu is running but the v2t engine is off") - if menubar.running(): + elif menubar.running(): raise SystemExit("quit Voice2Text before starting the login service") - if config.running_pid() is not None: + if menu_pid is None and config.running_pid() is not None: raise SystemExit( "v2t is already running outside the login service; stop it first" ) _prepare_log() - if loaded(): - _launchctl("kickstart", target()) - else: - _launchctl("bootstrap", f"gui/{os.getuid()}", str(path)) - deadline = time.monotonic() + 3 + if menu_pid is None: + if loaded(): + _launchctl("kickstart", target()) + else: + _launchctl("bootstrap", f"gui/{os.getuid()}", str(path)) + seen_service = menu_pid is not None + deadline = time.monotonic() + START_TIMEOUT while time.monotonic() < deadline: - if config.running_pid() is not None: + if engine_ready(): return if error := config.read_last_error(): raise RuntimeError(error) + current_service = service_pid() + if current_service is not None: + seen_service = True + elif seen_service: + raise RuntimeError( + f"service stopped during startup; check {config.run_dir() / 'v2t.log'}" + ) time.sleep(0.1) - if service_pid() is not None: - raise RuntimeError("Voice2Text menu started but the v2t engine is off") - raise RuntimeError(f"service did not start; check {config.run_dir() / 'v2t.log'}") + if not seen_service: + raise RuntimeError( + f"service did not start; check {config.run_dir() / 'v2t.log'}" + ) + raise RuntimeError( + f"models did not become ready within {START_TIMEOUT}s; " + f"check {config.run_dir() / 'v2t.log'}" + ) def stop() -> None: From dcdc37348ec0886dc788cfa427f15d2b37c29e4a Mon Sep 17 00:00:00 2001 From: lucharo Date: Thu, 23 Jul 2026 17:10:51 +0200 Subject: [PATCH 22/38] fix: close shutdown races --- README.md | 2 +- tests/test_smoke.py | 26 ++++++++++++++++++++++++++ v2t/app.py | 26 ++++++++++++++++++++------ v2t/backends.py | 2 +- v2t/config.py | 2 +- 5 files changed, 49 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index acc617b..66e7a10 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ as dependencies, with no daemon. The models download from Hugging Face on first in the local cache: ```bash -uv tool install voice2text # Parakeet STT + in-process Qwen3 cleanup +uv tool install voice2text # Parakeet STT + in-process Qwen2.5 cleanup v2t setup # optional: pick models, detect Ollama, write config v2t ``` diff --git a/tests/test_smoke.py b/tests/test_smoke.py index beb57ea..4a637b0 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -190,11 +190,37 @@ def test_signal_waits_for_the_active_transcription_before_cleanup(self): self.assertTrue(voice.stopping) self.assertEqual(config.running_pid(), os.getpid()) + self.assertEqual(config.read_status()["state"], "stopping") self.assertIsNone(voice.jobs.get_nowait()) + with self.assertRaises(SystemExit): + voice._handle_signal() voice.shutdown() self.assertIsNone(config.running_pid()) + def test_signal_exits_promptly_when_no_transcription_is_active(self): + voice = app.VoiceToText(config.Config(cleanup_enabled=False)) + voice.instance_lock = config.acquire_instance_lock() + + with self.assertRaises(SystemExit): + voice._handle_signal() + + voice.shutdown() + self.assertIsNone(config.running_pid()) + + def test_shutdown_blocks_late_recording_and_closes_a_racing_stream(self): + voice = app.VoiceToText(config.Config(cleanup_enabled=False)) + voice.stopping = True + stream = voice.stream = mock.Mock() + + with mock.patch.object(app.sd, "InputStream") as input_stream: + voice.start_recording() + voice.shutdown() + + input_stream.assert_not_called() + stream.stop.assert_called_once() + stream.close.assert_called_once() + def test_transcription_pipeline_pastes_cleanup_and_deletes_audio(self): voice = app.VoiceToText(config.Config(save_history=False)) audio_path = None diff --git a/v2t/app.py b/v2t/app.py index 7f5a440..233ba4b 100644 --- a/v2t/app.py +++ b/v2t/app.py @@ -100,8 +100,8 @@ def __init__(self, cfg: Config): } # --- live status for CLI and menu-bar clients -------------------------- - # state: starting | idle | recording | transcribing | cleaning. Written on - # every transition (and the icon repainted) so actions get instant feedback. + # Written on every transition (and the icon repainted) so actions get + # immediate feedback, including loading, active work, errors, and shutdown. def _set_state(self, state: str, error: str = "") -> None: clean_error = " ".join(error.split()) config.write_status( @@ -141,7 +141,7 @@ def audio_callback(self, indata, frame_count, time_info, status): self.frames.append(indata.copy()) def start_recording(self): - if self.recording or self.processing: + if self.stopping or self.recording or self.processing: return self.frames = [] self.record_start = time.perf_counter() @@ -419,16 +419,30 @@ def run(self): def _handle_signal(self, *_): if self.stopping: - return - logger.info("Finishing the active transcription before shutdown...") + logger.warning("Forcing shutdown...") + raise SystemExit(0) + logger.info( + "Finishing the active transcription before shutdown..." + if self.processing + else "Shutting down..." + ) self.stopping = True self.recording = False + self._set_state("stopping") self.jobs.put(None) self._close_stream() self._restore_media() + if not self.processing: + raise SystemExit(0) def shutdown(self) -> None: - self._handle_signal() + if not self.stopping: + self.stopping = True + self._set_state("stopping") + self.jobs.put(None) + self.recording = False + self._close_stream() + self._restore_media() self._clear_status() if self.instance_lock is not None: self.instance_lock.close() diff --git a/v2t/backends.py b/v2t/backends.py index 63f6770..9cb8e0c 100644 --- a/v2t/backends.py +++ b/v2t/backends.py @@ -79,7 +79,7 @@ def make_stt(backend: str, model: str = ""): class MLXCleanup: """In-process cleanup via mlx-lm — no daemon, no HTTP. The default. Pick a - non-thinking instruct model (the default Qwen3-Instruct-2507 doesn't think).""" + non-thinking instruct model (the default Qwen2.5-Instruct doesn't think).""" default_model = MLX_CLEANUP_DEFAULT diff --git a/v2t/config.py b/v2t/config.py index bdcb914..e06836c 100644 --- a/v2t/config.py +++ b/v2t/config.py @@ -6,7 +6,7 @@ run/status.json live state for CLI and menu-bar clients Zero config works: the defaults below are the shipped behaviour -(Parakeet + Qwen3, MLX, strict cleanup). +(Parakeet + Qwen2.5, MLX, strict cleanup). """ from __future__ import annotations From a48bfea0998f08948c5cc3e8d9f86585b811f61f Mon Sep 17 00:00:00 2001 From: lucharo Date: Thu, 23 Jul 2026 17:17:09 +0200 Subject: [PATCH 23/38] fix: keep signal handling reentrant --- tests/test_smoke.py | 28 ++++++++++++++++++++++++++-- v2t/app.py | 25 ++++++++----------------- v2t/service.py | 1 + 3 files changed, 35 insertions(+), 19 deletions(-) diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 4a637b0..e531554 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -6,6 +6,7 @@ import io import os import plistlib +import queue import stat import sys import tempfile @@ -185,13 +186,16 @@ def test_signal_waits_for_the_active_transcription_before_cleanup(self): voice = app.VoiceToText(config.Config(cleanup_enabled=False)) voice.instance_lock = config.acquire_instance_lock() voice.processing = True + voice._set_state("transcribing") voice._handle_signal() self.assertTrue(voice.stopping) self.assertEqual(config.running_pid(), os.getpid()) - self.assertEqual(config.read_status()["state"], "stopping") - self.assertIsNone(voice.jobs.get_nowait()) + voice._set_state("cleaning") + self.assertEqual(config.read_status()["state"], "transcribing") + with self.assertRaises(queue.Empty): + voice.jobs.get_nowait() with self.assertRaises(SystemExit): voice._handle_signal() @@ -210,6 +214,7 @@ def test_signal_exits_promptly_when_no_transcription_is_active(self): def test_shutdown_blocks_late_recording_and_closes_a_racing_stream(self): voice = app.VoiceToText(config.Config(cleanup_enabled=False)) + config.ensure_dirs() voice.stopping = True stream = voice.stream = mock.Mock() @@ -481,6 +486,25 @@ def test_service_start_waits_until_models_are_ready(self): self.assertEqual(status.call_count, 2) + def test_service_start_clears_a_stale_error_before_bootstrap(self): + plist = Path(self.tempdir.name) / "com.lucharo.voice2text.plist" + plist.touch() + with ( + mock.patch.object(service, "plist_path", return_value=plist), + mock.patch.object(service, "service_pid", side_effect=[None, 42]), + mock.patch.object(service.menubar, "running", return_value=False), + mock.patch.object(config, "running_pid", return_value=None), + mock.patch.object(service, "loaded", return_value=True), + mock.patch.object(service, "engine_ready", side_effect=[False, True]), + mock.patch.object(config, "clear_last_error") as clear_error, + mock.patch.object(config, "read_last_error", return_value=""), + mock.patch.object(service, "_launchctl"), + mock.patch.object(service.time, "sleep"), + ): + service.start() + + clear_error.assert_called_once() + def test_cleanup_refuses_to_return_a_capped_partial_result(self): cleaner = object.__new__(backends.MLXCleanup) cleaner.model = object() diff --git a/v2t/app.py b/v2t/app.py index 233ba4b..24baf64 100644 --- a/v2t/app.py +++ b/v2t/app.py @@ -103,6 +103,8 @@ def __init__(self, cfg: Config): # Written on every transition (and the icon repainted) so actions get # immediate feedback, including loading, active work, errors, and shutdown. def _set_state(self, state: str, error: str = "") -> None: + if self.stopping and state != "stopping": + return clean_error = " ".join(error.split()) config.write_status( { @@ -401,8 +403,9 @@ def run(self): with keyboard.Listener( on_press=self.on_press, on_release=self.on_release ) as listener: - while listener.is_alive() and self.process_next(timeout=0.25): - pass + while listener.is_alive() and not self.stopping: + if not self.process_next(timeout=0.25): + break if not self.stopping and not listener.is_alive(): raise RuntimeError("global hotkey listener stopped unexpectedly") except KeyboardInterrupt: @@ -419,27 +422,15 @@ def run(self): def _handle_signal(self, *_): if self.stopping: - logger.warning("Forcing shutdown...") raise SystemExit(0) - logger.info( - "Finishing the active transcription before shutdown..." - if self.processing - else "Shutting down..." - ) self.stopping = True - self.recording = False - self._set_state("stopping") - self.jobs.put(None) - self._close_stream() - self._restore_media() if not self.processing: raise SystemExit(0) def shutdown(self) -> None: - if not self.stopping: - self.stopping = True - self._set_state("stopping") - self.jobs.put(None) + self.stopping = True + self._set_state("stopping") + self.jobs.put(None) self.recording = False self._close_stream() self._restore_media() diff --git a/v2t/service.py b/v2t/service.py index 87c83d3..880c0ac 100644 --- a/v2t/service.py +++ b/v2t/service.py @@ -121,6 +121,7 @@ def start() -> None: ) _prepare_log() if menu_pid is None: + config.clear_last_error() if loaded(): _launchctl("kickstart", target()) else: From fab1dde2beb678e23e22909c403629da1e91fb5e Mon Sep 17 00:00:00 2001 From: lucharo Date: Thu, 23 Jul 2026 17:22:34 +0200 Subject: [PATCH 24/38] fix: serialize engine lifecycle --- tests/test_smoke.py | 45 +++++++++++++-- v2t/app.py | 109 +++++++++++++++++++----------------- v2t/cli.py | 2 +- v2t/native/Voice2Text.swift | 4 +- v2t/service.py | 10 +++- 5 files changed, 111 insertions(+), 59 deletions(-) diff --git a/tests/test_smoke.py b/tests/test_smoke.py index e531554..10f38e4 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -7,6 +7,7 @@ import os import plistlib import queue +import signal import stat import sys import tempfile @@ -226,6 +227,26 @@ def test_shutdown_blocks_late_recording_and_closes_a_racing_stream(self): stream.stop.assert_called_once() stream.close.assert_called_once() + def test_shutdown_wins_if_requested_while_the_stream_starts(self): + voice = app.VoiceToText( + config.Config(cleanup_enabled=False, pause_music=True) + ) + stream = mock.Mock() + stream.start.side_effect = lambda: setattr(voice, "stopping", True) + + with ( + mock.patch.object(app.sd, "InputStream", return_value=stream), + mock.patch.object(app.subprocess, "run") as run, + ): + voice.start_recording() + + self.assertFalse(voice.recording) + stream.stop.assert_called_once() + stream.close.assert_called_once() + self.assertNotIn( + ["nowplaying-cli", "pause"], [call.args[0] for call in run.call_args_list] + ) + def test_transcription_pipeline_pastes_cleanup_and_deletes_audio(self): voice = app.VoiceToText(config.Config(save_history=False)) audio_path = None @@ -458,18 +479,21 @@ def test_service_start_does_not_restart_a_healthy_process(self): launchctl.assert_not_called() - def test_service_start_rejects_a_menu_with_no_engine(self): + def test_service_start_restarts_a_launchd_menu_with_no_engine(self): plist = Path(self.tempdir.name) / "com.lucharo.voice2text.plist" plist.touch() with ( mock.patch.object(service, "plist_path", return_value=plist), mock.patch.object(service, "service_pid", return_value=42), - mock.patch.object(config, "read_status", return_value=None), - mock.patch.object(config, "read_last_error", return_value="engine is off"), - self.assertRaisesRegex(RuntimeError, "engine is off"), + mock.patch.object(service, "engine_ready", side_effect=[False, True]), + mock.patch.object(config, "running_pid", return_value=None), + mock.patch.object(config, "clear_last_error"), + mock.patch.object(service, "_launchctl") as launchctl, ): service.start() + launchctl.assert_called_once_with("kickstart", "-k", service.target()) + def test_service_start_waits_until_models_are_ready(self): plist = Path(self.tempdir.name) / "com.lucharo.voice2text.plist" plist.touch() @@ -480,6 +504,7 @@ def test_service_start_waits_until_models_are_ready(self): with ( mock.patch.object(service, "plist_path", return_value=plist), mock.patch.object(service, "service_pid", return_value=42), + mock.patch.object(config, "running_pid", return_value=84), mock.patch.object(config, "read_status", side_effect=statuses) as status, ): service.start() @@ -505,6 +530,18 @@ def test_service_start_clears_a_stale_error_before_bootstrap(self): clear_error.assert_called_once() + def test_stop_reports_graceful_shutdown_honestly(self): + output = io.StringIO() + with ( + mock.patch.object(config, "running_pid", return_value=42), + mock.patch.object(cli.os, "kill") as kill, + contextlib.redirect_stdout(output), + ): + cli.cmd_stop([]) + + kill.assert_called_once_with(42, signal.SIGTERM) + self.assertEqual(output.getvalue(), "stopping v2t (pid 42)\n") + def test_cleanup_refuses_to_return_a_capped_partial_result(self): cleaner = object.__new__(backends.MLXCleanup) cleaner.model = object() diff --git a/v2t/app.py b/v2t/app.py index 24baf64..d6b72af 100644 --- a/v2t/app.py +++ b/v2t/app.py @@ -11,6 +11,7 @@ import subprocess import sys import tempfile +import threading import time from pathlib import Path @@ -86,6 +87,7 @@ def __init__(self, cfg: Config): self.was_playing = False self._warned_mic = False self.jobs = queue.Queue() + self.lifecycle_lock = threading.RLock() self.instance_lock = None self.stopping = False cleanup_model = ( @@ -143,56 +145,62 @@ def audio_callback(self, indata, frame_count, time_info, status): self.frames.append(indata.copy()) def start_recording(self): - if self.stopping or self.recording or self.processing: - return - self.frames = [] - self.record_start = time.perf_counter() - try: - self.stream = sd.InputStream( - samplerate=self.cfg.sample_rate, - channels=1, - dtype="float32", - callback=self.audio_callback, - ) - self.recording = True - self.stream.start() - except Exception as error: - self.recording = False - self._close_stream() - logger.error(f"Could not open the microphone: {error}") - self._set_state("error", f"Microphone unavailable: {error}") - if not self._warned_mic: - self._warned_mic = True - subprocess.run(["open", MIC_PANE], check=False) - return + with self.lifecycle_lock: + if self.stopping or self.recording or self.processing: + return + self.frames = [] + self.record_start = time.perf_counter() + try: + self.stream = sd.InputStream( + samplerate=self.cfg.sample_rate, + channels=1, + dtype="float32", + callback=self.audio_callback, + ) + self.recording = True + self.stream.start() + except Exception as error: + self.recording = False + self._close_stream() + logger.error(f"Could not open the microphone: {error}") + self._set_state("error", f"Microphone unavailable: {error}") + if not self._warned_mic: + self._warned_mic = True + subprocess.run(["open", MIC_PANE], check=False) + return + if self.stopping: + self.recording = False + self._close_stream() + return - if self.cfg.pause_music: - result = subprocess.run( - ["nowplaying-cli", "get", "playbackRate"], - capture_output=True, - text=True, - check=False, - ) - self.was_playing = result.stdout.strip() == "1" - if self.was_playing: - subprocess.run(["nowplaying-cli", "pause"], check=False) - self._set_state("recording") - logger.info("Recording...") + if self.cfg.pause_music: + result = subprocess.run( + ["nowplaying-cli", "get", "playbackRate"], + capture_output=True, + text=True, + check=False, + ) + self.was_playing = result.stdout.strip() == "1" + if self.was_playing: + subprocess.run(["nowplaying-cli", "pause"], check=False) + self._set_state("recording") + logger.info("Recording...") def stop_recording(self): - if not self.recording: - return - self.recording = False - duration = time.perf_counter() - self.record_start - self._close_stream() - logger.info(f"Stopped ({duration:.1f}s)") - if self.frames: - frames, self.frames = self.frames, [] - self.processing = True - self.jobs.put((frames, duration)) - else: - self._restore_media() - self._set_state("idle") + with self.lifecycle_lock: + if not self.recording: + return + self.recording = False + duration = time.perf_counter() - self.record_start + self._close_stream() + logger.info(f"Stopped ({duration:.1f}s)") + if self.frames: + frames, self.frames = self.frames, [] + self.processing = True + self.jobs.put((frames, duration)) + else: + self._restore_media() + self._set_state("idle") def process_audio(self, frames: list[np.ndarray], audio_s: float): next_state, error_message, temp_path = "idle", "", None @@ -431,9 +439,10 @@ def shutdown(self) -> None: self.stopping = True self._set_state("stopping") self.jobs.put(None) - self.recording = False - self._close_stream() - self._restore_media() + with self.lifecycle_lock: + self.recording = False + self._close_stream() + self._restore_media() self._clear_status() if self.instance_lock is not None: self.instance_lock.close() diff --git a/v2t/cli.py b/v2t/cli.py index 723467f..610a709 100644 --- a/v2t/cli.py +++ b/v2t/cli.py @@ -232,7 +232,7 @@ def cmd_stop(argv: list[str]) -> int: print("not running") return 1 os.kill(pid, signal.SIGTERM) - print(f"stopped v2t (pid {pid})") + print(f"stopping v2t (pid {pid})") return 0 diff --git a/v2t/native/Voice2Text.swift b/v2t/native/Voice2Text.swift index dd71929..9e0ec9c 100644 --- a/v2t/native/Voice2Text.swift +++ b/v2t/native/Voice2Text.swift @@ -165,7 +165,9 @@ final class Voice2TextMenu: NSObject, NSApplicationDelegate { live = true status = value externalEngine = engine == nil - phase = value["state"] as? String ?? phase + if phase != "stopping" { + phase = value["state"] as? String ?? phase + } } } if !live && engine == nil && phase != "permission-error" && phase != "error" { diff --git a/v2t/service.py b/v2t/service.py index 880c0ac..284f2c0 100644 --- a/v2t/service.py +++ b/v2t/service.py @@ -120,13 +120,17 @@ def start() -> None: "v2t is already running outside the login service; stop it first" ) _prepare_log() - if menu_pid is None: + engine_pid = config.running_pid() + restarting = menu_pid is not None and engine_pid is None + if menu_pid is None or restarting: config.clear_last_error() - if loaded(): + if restarting: + _launchctl("kickstart", "-k", target()) + elif loaded(): _launchctl("kickstart", target()) else: _launchctl("bootstrap", f"gui/{os.getuid()}", str(path)) - seen_service = menu_pid is not None + seen_service = menu_pid is not None and not restarting deadline = time.monotonic() + START_TIMEOUT while time.monotonic() < deadline: if engine_ready(): From 74daf47f4423d4969ca35c8e7dc57b02f0f48dcf Mon Sep 17 00:00:00 2001 From: lucharo Date: Thu, 23 Jul 2026 17:27:29 +0200 Subject: [PATCH 25/38] fix: publish graceful shutdown state --- tests/test_smoke.py | 22 +++++++++++++++++++++- v2t/app.py | 15 +++++++++++++++ v2t/bench.py | 10 ++++++++-- v2t/native/Voice2Text.swift | 1 + 4 files changed, 45 insertions(+), 3 deletions(-) diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 10f38e4..9ac5ff7 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -188,13 +188,15 @@ def test_signal_waits_for_the_active_transcription_before_cleanup(self): voice.instance_lock = config.acquire_instance_lock() voice.processing = True voice._set_state("transcribing") + voice._start_shutdown_watcher() voice._handle_signal() + voice.shutdown_watcher.join(timeout=1) self.assertTrue(voice.stopping) self.assertEqual(config.running_pid(), os.getpid()) voice._set_state("cleaning") - self.assertEqual(config.read_status()["state"], "transcribing") + self.assertEqual(config.read_status()["state"], "stopping") with self.assertRaises(queue.Empty): voice.jobs.get_nowait() with self.assertRaises(SystemExit): @@ -556,6 +558,24 @@ def test_cleanup_refuses_to_return_a_capped_partial_result(self): with self.assertRaisesRegex(RuntimeError, "token limit"): cleaner.cleanup("hello") + def test_cleanup_benchmark_skips_a_missing_engine(self): + with ( + mock.patch.object( + backends, "make_cleanup", side_effect=SystemExit("missing dependency") + ), + contextlib.redirect_stdout(io.StringIO()), + ): + results = bench.bench_cleanup(["mlx:model"], ["sample"], 1, "") + + self.assertIsNone(results["mlx:model"]) + + def test_benchmark_repeat_must_be_positive(self): + with ( + contextlib.redirect_stderr(io.StringIO()), + self.assertRaises(SystemExit), + ): + bench.main(["--repeat", "0"]) + if __name__ == "__main__": unittest.main() diff --git a/v2t/app.py b/v2t/app.py index d6b72af..e027e9d 100644 --- a/v2t/app.py +++ b/v2t/app.py @@ -88,6 +88,7 @@ def __init__(self, cfg: Config): self._warned_mic = False self.jobs = queue.Queue() self.lifecycle_lock = threading.RLock() + self.shutdown_watcher = None self.instance_lock = None self.stopping = False cleanup_model = ( @@ -120,6 +121,17 @@ def _set_state(self, state: str, error: str = "") -> None: def _clear_status(self) -> None: config.clear_status() + def _start_shutdown_watcher(self) -> None: + def watch(): + while not self.stopping: + time.sleep(0.01) + self._set_state("stopping") + + self.shutdown_watcher = threading.Thread( + target=watch, name="v2t-shutdown-status", daemon=True + ) + self.shutdown_watcher.start() + def _close_stream(self) -> None: if self.stream is not None: stream, self.stream = self.stream, None @@ -399,6 +411,7 @@ def run(self): self.hotkey = _resolve_hotkey(self.cfg.hotkey) signal.signal(signal.SIGTERM, self._handle_signal) signal.signal(signal.SIGINT, self._handle_signal) + self._start_shutdown_watcher() try: self.warmup() self._set_state("idle") @@ -437,6 +450,8 @@ def _handle_signal(self, *_): def shutdown(self) -> None: self.stopping = True + if self.shutdown_watcher is not None: + self.shutdown_watcher.join() self._set_state("stopping") self.jobs.put(None) with self.lifecycle_lock: diff --git a/v2t/bench.py b/v2t/bench.py index 720febd..7a21ce7 100644 --- a/v2t/bench.py +++ b/v2t/bench.py @@ -150,7 +150,7 @@ def bench_cleanup(specs: list[str], samples: list[str], repeat: int, url: str) - try: cleaner = backends.make_cleanup(engine, model, url) cleaner.cleanup("hi") # warm / pull - except Exception as e: # missing model/engine shouldn't abort the whole run + except (Exception, SystemExit) as e: # one missing engine shouldn't abort the run print(f" skipped ({e})") results[spec] = None continue @@ -246,6 +246,12 @@ def write_results(body: str, out: Path | None) -> Path: def main(argv: list[str]) -> int: + def positive_int(value: str) -> int: + parsed = int(value) + if parsed < 1: + raise argparse.ArgumentTypeError("must be at least 1") + return parsed + config.ensure_dirs() p = argparse.ArgumentParser(prog="v2t bench") p.add_argument("--stt", action="store_true", help="run the STT table only") @@ -260,7 +266,7 @@ def main(argv: list[str]) -> int: help="engine:model specs (mlx:… or ollama:…)", ) p.add_argument( - "--repeat", type=int, default=3, help="runs per cell, median reported" + "--repeat", type=positive_int, default=3, help="runs per cell, median reported" ) p.add_argument( "--audio", diff --git a/v2t/native/Voice2Text.swift b/v2t/native/Voice2Text.swift index 9e0ec9c..e5dcaae 100644 --- a/v2t/native/Voice2Text.swift +++ b/v2t/native/Voice2Text.swift @@ -232,6 +232,7 @@ final class Voice2TextMenu: NSObject, NSApplicationDelegate { menu.addItem(.separator()) if externalEngine { add("Running from terminal", enabled: false) } else if phase == "permissions" || phase == "starting" { add("Starting…", enabled: false) } + else if phase == "stopping" { add("Stopping…", enabled: false) } else if engine == nil { add("Start v2t", action: #selector(start)) } else { add("Stop v2t", action: #selector(stop)) } menu.addItem(.separator()) From 68580f32dbfd2ea03f9922d537e626f7869c5f70 Mon Sep 17 00:00:00 2001 From: lucharo Date: Thu, 23 Jul 2026 17:31:44 +0200 Subject: [PATCH 26/38] fix: make shutdown notification efficient --- README.md | 2 +- tests/test_smoke.py | 10 +++++++++ v2t/app.py | 54 +++++++++++++++++++++++++++++++-------------- v2t/cli.py | 2 +- 4 files changed, 49 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 66e7a10..2bd2688 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ uv tool install 'voice2text[whisper]' # adds the Whisper backend; select it in ```bash # quick try (fresh venv each run — slower startup) -uvx voice2text +uvx --from voice2text v2t # pip pip install voice2text && v2t diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 9ac5ff7..920fa03 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -373,6 +373,16 @@ def test_setup_recommends_the_real_parakeet_install(self): self.assertNotIn("voice2text[parakeet]", output.getvalue()) self.assertEqual(stat.S_IMODE(config.config_path().stat().st_mode), 0o600) + def test_setup_quotes_the_whisper_extra_for_zsh(self): + output = io.StringIO() + with ( + mock.patch("builtins.input", side_effect=["2", "n"]), + contextlib.redirect_stdout(output), + ): + cli.cmd_setup([]) + + self.assertIn("uv tool install 'voice2text[whisper]'", output.getvalue()) + def test_menubar_install_builds_a_grantable_native_app(self): destination = Path(self.tempdir.name) / "Voice2Text.app" diff --git a/v2t/app.py b/v2t/app.py index e027e9d..69cb227 100644 --- a/v2t/app.py +++ b/v2t/app.py @@ -88,7 +88,10 @@ def __init__(self, cfg: Config): self._warned_mic = False self.jobs = queue.Queue() self.lifecycle_lock = threading.RLock() + self.status_lock = threading.Lock() self.shutdown_watcher = None + self.shutdown_read_fd = None + self.shutdown_write_fd = None self.instance_lock = None self.stopping = False cleanup_model = ( @@ -106,25 +109,28 @@ def __init__(self, cfg: Config): # Written on every transition (and the icon repainted) so actions get # immediate feedback, including loading, active work, errors, and shutdown. def _set_state(self, state: str, error: str = "") -> None: - if self.stopping and state != "stopping": - return - clean_error = " ".join(error.split()) - config.write_status( - { - "pid": os.getpid(), - "state": state, - **self.status_details, - "error": clean_error, - } - ) + with self.status_lock: + if self.stopping and state != "stopping": + return + clean_error = " ".join(error.split()) + config.write_status( + { + "pid": os.getpid(), + "state": state, + **self.status_details, + "error": clean_error, + } + ) def _clear_status(self) -> None: - config.clear_status() + with self.status_lock: + config.clear_status() def _start_shutdown_watcher(self) -> None: + self.shutdown_read_fd, self.shutdown_write_fd = os.pipe() + def watch(): - while not self.stopping: - time.sleep(0.01) + os.read(self.shutdown_read_fd, 1) self._set_state("stopping") self.shutdown_watcher = threading.Thread( @@ -409,9 +415,9 @@ def run(self): config.clear_last_error() config.ensure_dirs() self.hotkey = _resolve_hotkey(self.cfg.hotkey) + self._start_shutdown_watcher() signal.signal(signal.SIGTERM, self._handle_signal) signal.signal(signal.SIGINT, self._handle_signal) - self._start_shutdown_watcher() try: self.warmup() self._set_state("idle") @@ -445,20 +451,34 @@ def _handle_signal(self, *_): if self.stopping: raise SystemExit(0) self.stopping = True + if self.shutdown_write_fd is not None: + try: + os.write(self.shutdown_write_fd, b"\0") + except OSError: + pass if not self.processing: raise SystemExit(0) def shutdown(self) -> None: self.stopping = True + if self.shutdown_write_fd is not None: + try: + os.write(self.shutdown_write_fd, b"\0") + except OSError: + pass if self.shutdown_watcher is not None: self.shutdown_watcher.join() self._set_state("stopping") self.jobs.put(None) with self.lifecycle_lock: self.recording = False - self._close_stream() - self._restore_media() + self._close_stream() + self._restore_media() self._clear_status() + for fd in (self.shutdown_read_fd, self.shutdown_write_fd): + if fd is not None: + os.close(fd) + self.shutdown_read_fd = self.shutdown_write_fd = None if self.instance_lock is not None: self.instance_lock.close() self.instance_lock = None diff --git a/v2t/cli.py b/v2t/cli.py index 610a709..343d04d 100644 --- a/v2t/cli.py +++ b/v2t/cli.py @@ -178,7 +178,7 @@ def cmd_setup(argv: list[str]) -> int: print(f"\nwrote {path}") if cleanup_enabled and engine == "ollama": print("next: ollama pull qwen3:4b-instruct-2507") - package = "voice2text[whisper]" if backend == "whisper" else "voice2text" + package = "'voice2text[whisper]'" if backend == "whisper" else "voice2text" print(f"run: v2t (install with: uv tool install {package})") return 0 From 7eca4d855e29e79d9c21584b6c18e251e1781115 Mon Sep 17 00:00:00 2001 From: lucharo Date: Thu, 23 Jul 2026 17:35:52 +0200 Subject: [PATCH 27/38] fix: wait for engine before menu quit --- README.md | 2 +- benchmarks/results/GRID.md | 2 +- tests/test_smoke.py | 7 +++++++ v2t/cli.py | 5 +++-- v2t/native/Voice2Text.swift | 26 +++++++++++++++++++++----- 5 files changed, 33 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 2bd2688..e262451 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,7 @@ v2t menubar install # compile the bundled Swift file into ~/Applications/Vo The optional app is a single, inspectable Swift source file — no window, Xcode project, AppleScript, or separate settings system. It exists because macOS only grants microphone access to a real app -identity. The menu requests the three native grants, starts one long-running Python process, shows +identity. The menu requests the two native grants, starts one long-running Python process, shows state immediately, and links to config, history, and log. Run `v2t` in a terminal instead if you do not want the menu. diff --git a/benchmarks/results/GRID.md b/benchmarks/results/GRID.md index 54746a9..b35289f 100644 --- a/benchmarks/results/GRID.md +++ b/benchmarks/results/GRID.md @@ -1,7 +1,7 @@ # Cross-machine grid The rollup view: rows = machines, columns = models. Fill each cell by running -`v2t bench` on that machine (it also writes a detailed `-.md` here). +`just bench` on that machine (it also writes a detailed `-.md` here). Compare a column down the rows to see how a model scales with hardware. ## Speech-to-text — transcription time on the medium clip (RTF) diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 920fa03..96106e4 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -554,6 +554,13 @@ def test_stop_reports_graceful_shutdown_honestly(self): kill.assert_called_once_with(42, signal.SIGTERM) self.assertEqual(output.getvalue(), "stopping v2t (pid 42)\n") + def test_cleanup_modes_are_mutually_exclusive(self): + with ( + contextlib.redirect_stderr(io.StringIO()), + self.assertRaises(SystemExit), + ): + cli.cmd_run(["--casual", "--strict"]) + def test_cleanup_refuses_to_return_a_capped_partial_result(self): cleaner = object.__new__(backends.MLXCleanup) cleaner.model = object() diff --git a/v2t/cli.py b/v2t/cli.py index 343d04d..d28204e 100644 --- a/v2t/cli.py +++ b/v2t/cli.py @@ -37,12 +37,13 @@ def cmd_run(argv: list[str]) -> int: action="store_true", help="paste raw transcription, skip LLM cleanup", ) - p.add_argument( + cleanup_mode = p.add_mutually_exclusive_group() + cleanup_mode.add_argument( "--casual", action="store_true", help="light cleanup (punctuation + fillers only)", ) - p.add_argument( + cleanup_mode.add_argument( "--strict", action="store_true", help="full cleanup (restructures) — the default", diff --git a/v2t/native/Voice2Text.swift b/v2t/native/Voice2Text.swift index e5dcaae..edbd4aa 100644 --- a/v2t/native/Voice2Text.swift +++ b/v2t/native/Voice2Text.swift @@ -24,6 +24,7 @@ final class Voice2TextMenu: NSObject, NSApplicationDelegate { private var lockFD: Int32 = -1 private var logHandle: FileHandle? private var rendered = "" + private var terminationPending = false private var home: URL { let value = Bundle.main.object(forInfoDictionaryKey: "V2THome") as? String @@ -53,6 +54,15 @@ final class Voice2TextMenu: NSObject, NSApplicationDelegate { if lockFD >= 0 { close(lockFD) } } + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + guard let engine else { return .terminateNow } + terminationPending = true + phase = "stopping" + render() + engine.terminate() + return .terminateLater + } + private func acquireAppLock() -> Bool { let run = home.appendingPathComponent("run") try? FileManager.default.createDirectory(at: run, withIntermediateDirectories: true) @@ -131,11 +141,17 @@ final class Voice2TextMenu: NSObject, NSApplicationDelegate { } process.terminationHandler = { [weak self] _ in DispatchQueue.main.async { - self?.engine = nil - self?.logHandle?.closeFile() - self?.logHandle = nil - self?.phase = "off" - self?.refresh() + guard let self else { return } + let shouldQuit = self.terminationPending + self.engine = nil + self.logHandle?.closeFile() + self.logHandle = nil + self.phase = "off" + if shouldQuit { + NSApp.reply(toApplicationShouldTerminate: true) + } else { + self.refresh() + } } } do { From b05fadecde9d0fdb87ad62ef77d2afcf167fd584 Mon Sep 17 00:00:00 2001 From: lucharo Date: Thu, 23 Jul 2026 17:38:50 +0200 Subject: [PATCH 28/38] fix: wait for service shutdown --- tests/test_smoke.py | 10 ++++++++++ v2t/bench.py | 6 +++--- v2t/service.py | 12 ++++++++++-- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 96106e4..5d4d2e2 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -542,6 +542,16 @@ def test_service_start_clears_a_stale_error_before_bootstrap(self): clear_error.assert_called_once() + def test_service_stop_waits_for_menu_and_engine_to_exit(self): + with ( + mock.patch.object(service, "service_pid", side_effect=[42, None]), + mock.patch.object(config, "running_pid", return_value=None), + mock.patch.object(service, "_launchctl") as launchctl, + ): + service.stop() + + launchctl.assert_called_once_with("kill", "SIGTERM", service.target()) + def test_stop_reports_graceful_shutdown_honestly(self): output = io.StringIO() with ( diff --git a/v2t/bench.py b/v2t/bench.py index 7a21ce7..6d6c3d1 100644 --- a/v2t/bench.py +++ b/v2t/bench.py @@ -5,9 +5,9 @@ ~/.v2t/benchmarks/results/ so you can collect a grid across machines. Absolute times reflect THAT machine — compare models within a run, not across machines. - v2t bench # both tables, default models, samples via `say` - v2t bench --cleanup # cleanup table only - v2t bench --cleanup-models mlx:mlx-community/Qwen2.5-1.5B-Instruct-4bit mlx:mlx-community/Qwen3-4B-Instruct-2507-4bit + just bench # both tables, default models, samples via `say` + just bench --cleanup # cleanup table only + just bench --cleanup-models mlx:mlx-community/Qwen2.5-1.5B-Instruct-4bit """ from __future__ import annotations diff --git a/v2t/service.py b/v2t/service.py index 284f2c0..6ffe234 100644 --- a/v2t/service.py +++ b/v2t/service.py @@ -15,6 +15,7 @@ LABEL = menubar.BUNDLE_ID READY_STATES = {"idle", "recording", "transcribing", "cleaning"} START_TIMEOUT = 120 +STOP_TIMEOUT = 120 def plist_path() -> Path: @@ -156,8 +157,15 @@ def start() -> None: def stop() -> None: - if service_pid() is not None: - _launchctl("kill", "SIGTERM", target()) + if service_pid() is None: + return + _launchctl("kill", "SIGTERM", target()) + deadline = time.monotonic() + STOP_TIMEOUT + while time.monotonic() < deadline: + if service_pid() is None and config.running_pid() is None: + return + time.sleep(0.1) + raise RuntimeError(f"service is still stopping; check {config.run_dir() / 'v2t.log'}") def uninstall() -> None: From 6a1099e89dc98b7995778d779af7a74554e16e02 Mon Sep 17 00:00:00 2001 From: lucharo Date: Thu, 23 Jul 2026 17:41:13 +0200 Subject: [PATCH 29/38] fix: stop only the service-owned engine --- tests/test_smoke.py | 25 +++++++++++++++++++++++++ v2t/service.py | 34 +++++++++++++++++++++++++++++++--- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 5d4d2e2..fce6f84 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -552,6 +552,31 @@ def test_service_stop_waits_for_menu_and_engine_to_exit(self): launchctl.assert_called_once_with("kill", "SIGTERM", service.target()) + def test_service_stop_waits_only_for_its_owned_engine(self): + with ( + mock.patch.object(service, "service_pid", side_effect=[42, None]), + mock.patch.object(service, "owned_engine_pid", return_value=84), + mock.patch.object(config, "running_pid", side_effect=[84, None]), + mock.patch.object(service.os, "kill") as kill, + mock.patch.object(service, "_launchctl") as launchctl, + mock.patch.object(service.time, "sleep"), + ): + service.stop() + + kill.assert_called_once_with(84, signal.SIGTERM) + launchctl.assert_called_once_with("kill", "SIGTERM", service.target()) + + def test_service_preserves_an_external_engine(self): + with ( + mock.patch.object(service, "service_pid", side_effect=[42, None]), + mock.patch.object(service, "owned_engine_pid", return_value=None), + mock.patch.object(service.os, "kill") as kill, + mock.patch.object(service, "_launchctl"), + ): + service.stop() + + kill.assert_not_called() + def test_stop_reports_graceful_shutdown_honestly(self): output = io.StringIO() with ( diff --git a/v2t/service.py b/v2t/service.py index 6ffe234..b17f894 100644 --- a/v2t/service.py +++ b/v2t/service.py @@ -4,6 +4,7 @@ import os import plistlib +import signal import subprocess import sys import tempfile @@ -58,6 +59,21 @@ def engine_ready() -> bool: return bool(status and status.get("state") in READY_STATES) +def owned_engine_pid(menu_pid: int) -> int | None: + """Return the live v2t engine only when it is a child of this menu process.""" + engine_pid = config.running_pid() + if engine_pid is None: + return None + result = subprocess.run( + ["ps", "-o", "ppid=", "-p", str(engine_pid)], + capture_output=True, + text=True, + check=False, + ) + parent = result.stdout.strip() + return engine_pid if parent.isdigit() and int(parent) == menu_pid else None + + def plist_data() -> dict: log = config.run_dir() / "v2t.log" return { @@ -157,12 +173,24 @@ def start() -> None: def stop() -> None: - if service_pid() is None: + menu_pid = service_pid() + if menu_pid is None: return - _launchctl("kill", "SIGTERM", target()) + engine_pid = owned_engine_pid(menu_pid) deadline = time.monotonic() + STOP_TIMEOUT + if engine_pid is not None: + os.kill(engine_pid, signal.SIGTERM) + while time.monotonic() < deadline: + if config.running_pid() != engine_pid: + break + time.sleep(0.1) + else: + raise RuntimeError( + f"engine is still stopping; check {config.run_dir() / 'v2t.log'}" + ) + _launchctl("kill", "SIGTERM", target()) while time.monotonic() < deadline: - if service_pid() is None and config.running_pid() is None: + if service_pid() is None: return time.sleep(0.1) raise RuntimeError(f"service is still stopping; check {config.run_dir() / 'v2t.log'}") From a6dbc7722b87156a4639173a4f3c6793bfa3ebb8 Mon Sep 17 00:00:00 2001 From: lucharo Date: Thu, 23 Jul 2026 17:43:31 +0200 Subject: [PATCH 30/38] fix: make service stop idempotent --- tests/test_smoke.py | 29 +++++++++++++++++++++++++++++ v2t/service.py | 12 +++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/tests/test_smoke.py b/tests/test_smoke.py index fce6f84..1beb4fc 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -577,6 +577,35 @@ def test_service_preserves_an_external_engine(self): kill.assert_not_called() + def test_service_stop_does_not_force_an_engine_already_stopping(self): + with ( + mock.patch.object(service, "service_pid", side_effect=[42, None]), + mock.patch.object(service, "owned_engine_pid", return_value=84), + mock.patch.object( + config, "read_status", return_value={"pid": 84, "state": "stopping"} + ), + mock.patch.object(config, "running_pid", side_effect=[84, None]), + mock.patch.object(service.os, "kill") as kill, + mock.patch.object(service, "_launchctl"), + mock.patch.object(service.time, "sleep"), + ): + service.stop() + + kill.assert_not_called() + + def test_service_stop_tolerates_engine_exit_before_signal(self): + with ( + mock.patch.object(service, "service_pid", side_effect=[42, None]), + mock.patch.object(service, "owned_engine_pid", return_value=84), + mock.patch.object(config, "read_status", return_value=None), + mock.patch.object(config, "running_pid", return_value=None), + mock.patch.object( + service.os, "kill", side_effect=ProcessLookupError + ), + mock.patch.object(service, "_launchctl"), + ): + service.stop() + def test_stop_reports_graceful_shutdown_honestly(self): output = io.StringIO() with ( diff --git a/v2t/service.py b/v2t/service.py index b17f894..fb78f05 100644 --- a/v2t/service.py +++ b/v2t/service.py @@ -179,7 +179,17 @@ def stop() -> None: engine_pid = owned_engine_pid(menu_pid) deadline = time.monotonic() + STOP_TIMEOUT if engine_pid is not None: - os.kill(engine_pid, signal.SIGTERM) + runtime = config.read_status() + already_stopping = bool( + runtime + and runtime.get("pid") == engine_pid + and runtime.get("state") == "stopping" + ) + if not already_stopping: + try: + os.kill(engine_pid, signal.SIGTERM) + except ProcessLookupError: + pass while time.monotonic() < deadline: if config.running_pid() != engine_pid: break From d61b5b2b4e8e5b74246c841e51e9c1a65b0f2d87 Mon Sep 17 00:00:00 2001 From: lucharo Date: Thu, 23 Jul 2026 17:47:10 +0200 Subject: [PATCH 31/38] fix: keep graceful shutdown idempotent --- tests/test_smoke.py | 4 ++-- v2t/app.py | 2 +- v2t/service.py | 3 ++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 1beb4fc..5731261 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -199,8 +199,8 @@ def test_signal_waits_for_the_active_transcription_before_cleanup(self): self.assertEqual(config.read_status()["state"], "stopping") with self.assertRaises(queue.Empty): voice.jobs.get_nowait() - with self.assertRaises(SystemExit): - voice._handle_signal() + voice._handle_signal() + self.assertTrue(voice.stopping) voice.shutdown() self.assertIsNone(config.running_pid()) diff --git a/v2t/app.py b/v2t/app.py index 69cb227..8357a21 100644 --- a/v2t/app.py +++ b/v2t/app.py @@ -449,7 +449,7 @@ def run(self): def _handle_signal(self, *_): if self.stopping: - raise SystemExit(0) + return self.stopping = True if self.shutdown_write_fd is not None: try: diff --git a/v2t/service.py b/v2t/service.py index fb78f05..755a965 100644 --- a/v2t/service.py +++ b/v2t/service.py @@ -177,8 +177,8 @@ def stop() -> None: if menu_pid is None: return engine_pid = owned_engine_pid(menu_pid) - deadline = time.monotonic() + STOP_TIMEOUT if engine_pid is not None: + deadline = time.monotonic() + STOP_TIMEOUT runtime = config.read_status() already_stopping = bool( runtime @@ -199,6 +199,7 @@ def stop() -> None: f"engine is still stopping; check {config.run_dir() / 'v2t.log'}" ) _launchctl("kill", "SIGTERM", target()) + deadline = time.monotonic() + STOP_TIMEOUT while time.monotonic() < deadline: if service_pid() is None: return From c6270e068c274ce0ee7f701c1b51ea44c49e47a8 Mon Sep 17 00:00:00 2001 From: lucharo Date: Thu, 23 Jul 2026 17:53:39 +0200 Subject: [PATCH 32/38] fix: bound service shutdown escalation --- README.md | 2 +- tests/test_smoke.py | 42 ++++++++++++++++++++++++++++++++++------ v2t/app.py | 4 +++- v2t/cli.py | 10 ++++++++-- v2t/service.py | 47 ++++++++++++++++++++++++++++++++++++--------- 5 files changed, 86 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index e262451..2007568 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ v2t --pause-music # pause media while recording (needs nowplaying-cli) v2t setup # guided config: pick models, detect Ollama v2t status # running / idle (also used by the menu app) -v2t stop # stop a running v2t +v2t stop # stop a running v2t gracefully (--force if it is stuck) v2t config # show resolved config + paths (--init writes a template) v2t menubar install # optional: compile + open the tiny native menu app v2t service install # optional: start that menu app at login diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 5731261..c86dd13 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -190,7 +190,7 @@ def test_signal_waits_for_the_active_transcription_before_cleanup(self): voice._set_state("transcribing") voice._start_shutdown_watcher() - voice._handle_signal() + voice._handle_signal(signal.SIGTERM) voice.shutdown_watcher.join(timeout=1) self.assertTrue(voice.stopping) @@ -199,8 +199,10 @@ def test_signal_waits_for_the_active_transcription_before_cleanup(self): self.assertEqual(config.read_status()["state"], "stopping") with self.assertRaises(queue.Empty): voice.jobs.get_nowait() - voice._handle_signal() + voice._handle_signal(signal.SIGTERM) self.assertTrue(voice.stopping) + with self.assertRaises(SystemExit): + voice._handle_signal(signal.SIGINT) voice.shutdown() self.assertIsNone(config.running_pid()) @@ -545,18 +547,36 @@ def test_service_start_clears_a_stale_error_before_bootstrap(self): def test_service_stop_waits_for_menu_and_engine_to_exit(self): with ( mock.patch.object(service, "service_pid", side_effect=[42, None]), - mock.patch.object(config, "running_pid", return_value=None), + mock.patch.object(service, "owned_engine_pid", return_value=None), mock.patch.object(service, "_launchctl") as launchctl, ): service.stop() launchctl.assert_called_once_with("kill", "SIGTERM", service.target()) + def test_service_finds_its_child_before_engine_status_exists(self): + with ( + mock.patch.object(config, "running_pid", return_value=None), + mock.patch.object( + service.subprocess, + "run", + return_value=mock.Mock(stdout="84\n", returncode=0), + ) as run, + ): + self.assertEqual(service.owned_engine_pid(42), 84) + + run.assert_called_once_with( + ["pgrep", "-P", "42"], + capture_output=True, + text=True, + check=False, + ) + def test_service_stop_waits_only_for_its_owned_engine(self): with ( mock.patch.object(service, "service_pid", side_effect=[42, None]), mock.patch.object(service, "owned_engine_pid", return_value=84), - mock.patch.object(config, "running_pid", side_effect=[84, None]), + mock.patch.object(service, "_pid_alive", side_effect=[True, False]), mock.patch.object(service.os, "kill") as kill, mock.patch.object(service, "_launchctl") as launchctl, mock.patch.object(service.time, "sleep"), @@ -584,7 +604,7 @@ def test_service_stop_does_not_force_an_engine_already_stopping(self): mock.patch.object( config, "read_status", return_value={"pid": 84, "state": "stopping"} ), - mock.patch.object(config, "running_pid", side_effect=[84, None]), + mock.patch.object(service, "_pid_alive", side_effect=[True, False]), mock.patch.object(service.os, "kill") as kill, mock.patch.object(service, "_launchctl"), mock.patch.object(service.time, "sleep"), @@ -598,7 +618,7 @@ def test_service_stop_tolerates_engine_exit_before_signal(self): mock.patch.object(service, "service_pid", side_effect=[42, None]), mock.patch.object(service, "owned_engine_pid", return_value=84), mock.patch.object(config, "read_status", return_value=None), - mock.patch.object(config, "running_pid", return_value=None), + mock.patch.object(service, "_pid_alive", return_value=False), mock.patch.object( service.os, "kill", side_effect=ProcessLookupError ), @@ -618,6 +638,16 @@ def test_stop_reports_graceful_shutdown_honestly(self): kill.assert_called_once_with(42, signal.SIGTERM) self.assertEqual(output.getvalue(), "stopping v2t (pid 42)\n") + def test_force_stop_uses_sigkill(self): + with ( + mock.patch.object(config, "running_pid", return_value=42), + mock.patch.object(cli.os, "kill") as kill, + contextlib.redirect_stdout(io.StringIO()), + ): + cli.cmd_stop(["--force"]) + + kill.assert_called_once_with(42, signal.SIGKILL) + def test_cleanup_modes_are_mutually_exclusive(self): with ( contextlib.redirect_stderr(io.StringIO()), diff --git a/v2t/app.py b/v2t/app.py index 8357a21..7a94ab0 100644 --- a/v2t/app.py +++ b/v2t/app.py @@ -447,8 +447,10 @@ def run(self): finally: self.shutdown() - def _handle_signal(self, *_): + def _handle_signal(self, signum=None, _frame=None): if self.stopping: + if signum == signal.SIGINT: + raise SystemExit(0) return self.stopping = True if self.shutdown_write_fd is not None: diff --git a/v2t/cli.py b/v2t/cli.py index d28204e..44b8120 100644 --- a/v2t/cli.py +++ b/v2t/cli.py @@ -227,13 +227,19 @@ def cmd_status(argv: list[str]) -> int: def cmd_stop(argv: list[str]) -> int: + parser = argparse.ArgumentParser(prog="v2t stop") + parser.add_argument( + "--force", action="store_true", help="stop immediately instead of waiting" + ) + args = parser.parse_args(argv) pid = config.running_pid() if pid is None: config.clear_status() print("not running") return 1 - os.kill(pid, signal.SIGTERM) - print(f"stopping v2t (pid {pid})") + os.kill(pid, signal.SIGKILL if args.force else signal.SIGTERM) + action = "force-stopped" if args.force else "stopping" + print(f"{action} v2t (pid {pid})") return 0 diff --git a/v2t/service.py b/v2t/service.py index 755a965..cd0a40f 100644 --- a/v2t/service.py +++ b/v2t/service.py @@ -17,6 +17,7 @@ READY_STATES = {"idle", "recording", "transcribing", "cleaning"} START_TIMEOUT = 120 STOP_TIMEOUT = 120 +FORCE_TIMEOUT = 5 def plist_path() -> Path: @@ -62,16 +63,36 @@ def engine_ready() -> bool: def owned_engine_pid(menu_pid: int) -> int | None: """Return the live v2t engine only when it is a child of this menu process.""" engine_pid = config.running_pid() - if engine_pid is None: - return None + if engine_pid is not None: + result = subprocess.run( + ["ps", "-o", "ppid=", "-p", str(engine_pid)], + capture_output=True, + text=True, + check=False, + ) + parent = result.stdout.strip() + if parent.isdigit() and int(parent) == menu_pid: + return engine_pid result = subprocess.run( - ["ps", "-o", "ppid=", "-p", str(engine_pid)], + ["pgrep", "-P", str(menu_pid)], capture_output=True, text=True, check=False, ) - parent = result.stdout.strip() - return engine_pid if parent.isdigit() and int(parent) == menu_pid else None + return next( + (int(line) for line in result.stdout.splitlines() if line.strip().isdigit()), + None, + ) + + +def _pid_alive(pid: int) -> bool: + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + return True def plist_data() -> dict: @@ -191,13 +212,21 @@ def stop() -> None: except ProcessLookupError: pass while time.monotonic() < deadline: - if config.running_pid() != engine_pid: + if not _pid_alive(engine_pid): break time.sleep(0.1) else: - raise RuntimeError( - f"engine is still stopping; check {config.run_dir() / 'v2t.log'}" - ) + try: + os.kill(engine_pid, signal.SIGKILL) + except ProcessLookupError: + pass + force_deadline = time.monotonic() + FORCE_TIMEOUT + while time.monotonic() < force_deadline and _pid_alive(engine_pid): + time.sleep(0.1) + if _pid_alive(engine_pid): + raise RuntimeError( + f"engine did not stop; check {config.run_dir() / 'v2t.log'}" + ) _launchctl("kill", "SIGTERM", target()) deadline = time.monotonic() + STOP_TIMEOUT while time.monotonic() < deadline: From 67c259b316f4a6e22535bc130386ee8836974b8c Mon Sep 17 00:00:00 2001 From: lucharo Date: Thu, 23 Jul 2026 17:56:34 +0200 Subject: [PATCH 33/38] fix: preserve launchd child during startup --- tests/test_smoke.py | 17 +++++++++++++++++ v2t/service.py | 6 ++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/tests/test_smoke.py b/tests/test_smoke.py index c86dd13..37be73c 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -501,6 +501,7 @@ def test_service_start_restarts_a_launchd_menu_with_no_engine(self): mock.patch.object(service, "service_pid", return_value=42), mock.patch.object(service, "engine_ready", side_effect=[False, True]), mock.patch.object(config, "running_pid", return_value=None), + mock.patch.object(service, "owned_engine_pid", return_value=None), mock.patch.object(config, "clear_last_error"), mock.patch.object(service, "_launchctl") as launchctl, ): @@ -508,6 +509,21 @@ def test_service_start_restarts_a_launchd_menu_with_no_engine(self): launchctl.assert_called_once_with("kickstart", "-k", service.target()) + def test_service_start_waits_for_a_prelock_child(self): + plist = Path(self.tempdir.name) / "com.lucharo.voice2text.plist" + plist.touch() + with ( + mock.patch.object(service, "plist_path", return_value=plist), + mock.patch.object(service, "service_pid", return_value=42), + mock.patch.object(service, "engine_ready", side_effect=[False, True]), + mock.patch.object(config, "running_pid", return_value=None), + mock.patch.object(service, "owned_engine_pid", return_value=84), + mock.patch.object(service, "_launchctl") as launchctl, + ): + service.start() + + launchctl.assert_not_called() + def test_service_start_waits_until_models_are_ready(self): plist = Path(self.tempdir.name) / "com.lucharo.voice2text.plist" plist.touch() @@ -519,6 +535,7 @@ def test_service_start_waits_until_models_are_ready(self): mock.patch.object(service, "plist_path", return_value=plist), mock.patch.object(service, "service_pid", return_value=42), mock.patch.object(config, "running_pid", return_value=84), + mock.patch.object(service, "owned_engine_pid", return_value=84), mock.patch.object(config, "read_status", side_effect=statuses) as status, ): service.start() diff --git a/v2t/service.py b/v2t/service.py index cd0a40f..d097419 100644 --- a/v2t/service.py +++ b/v2t/service.py @@ -158,8 +158,10 @@ def start() -> None: "v2t is already running outside the login service; stop it first" ) _prepare_log() - engine_pid = config.running_pid() - restarting = menu_pid is not None and engine_pid is None + menu_engine_pid = ( + owned_engine_pid(menu_pid) if menu_pid is not None else None + ) + restarting = menu_pid is not None and menu_engine_pid is None if menu_pid is None or restarting: config.clear_last_error() if restarting: From c0e49ae62d709fb5164e4dbd8ec63e4598ddfc25 Mon Sep 17 00:00:00 2001 From: lucharo Date: Thu, 23 Jul 2026 17:59:57 +0200 Subject: [PATCH 34/38] fix: drain accepted jobs before shutdown --- tests/test_smoke.py | 44 +++++++++++++++++++++++++++++++++++++++++++- v2t/app.py | 4 +++- v2t/service.py | 3 ++- 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 37be73c..d613db9 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -217,6 +217,46 @@ def test_signal_exits_promptly_when_no_transcription_is_active(self): voice.shutdown() self.assertIsNone(config.running_pid()) + def test_run_drains_an_accepted_job_before_shutdown(self): + voice = app.VoiceToText(config.Config(cleanup_enabled=False)) + + class Listener: + def __init__(self, **_callbacks): + pass + + def __enter__(self): + return self + + def __exit__(self, *_): + pass + + def is_alive(self): + return True + + pynput = types.ModuleType("pynput") + pynput.keyboard = types.SimpleNamespace(Listener=Listener) + + def queue_then_stop(): + voice.processing = True + voice.stopping = True + voice.jobs.put(([np.ones((8, 1), dtype=np.float32)], 1.0)) + + def finish_job(*_): + voice.processing = False + + with ( + mock.patch.dict(sys.modules, {"pynput": pynput}), + mock.patch.object(app, "_resolve_hotkey", return_value=object()), + mock.patch.object(app.signal, "signal"), + mock.patch.object(voice, "warmup", side_effect=queue_then_stop), + mock.patch.object( + voice, "process_audio", side_effect=finish_job + ) as process, + ): + voice.run() + + process.assert_called_once() + def test_shutdown_blocks_late_recording_and_closes_a_racing_stream(self): voice = app.VoiceToText(config.Config(cleanup_enabled=False)) config.ensure_dirs() @@ -503,11 +543,13 @@ def test_service_start_restarts_a_launchd_menu_with_no_engine(self): mock.patch.object(config, "running_pid", return_value=None), mock.patch.object(service, "owned_engine_pid", return_value=None), mock.patch.object(config, "clear_last_error"), + mock.patch.object(service, "stop") as stop, mock.patch.object(service, "_launchctl") as launchctl, ): service.start() - launchctl.assert_called_once_with("kickstart", "-k", service.target()) + stop.assert_called_once() + launchctl.assert_called_once_with("kickstart", service.target()) def test_service_start_waits_for_a_prelock_child(self): plist = Path(self.tempdir.name) / "com.lucharo.voice2text.plist" diff --git a/v2t/app.py b/v2t/app.py index 7a94ab0..66fdec7 100644 --- a/v2t/app.py +++ b/v2t/app.py @@ -430,7 +430,9 @@ def run(self): with keyboard.Listener( on_press=self.on_press, on_release=self.on_release ) as listener: - while listener.is_alive() and not self.stopping: + while listener.is_alive() and ( + not self.stopping or self.processing + ): if not self.process_next(timeout=0.25): break if not self.stopping and not listener.is_alive(): diff --git a/v2t/service.py b/v2t/service.py index d097419..47a44ff 100644 --- a/v2t/service.py +++ b/v2t/service.py @@ -165,7 +165,8 @@ def start() -> None: if menu_pid is None or restarting: config.clear_last_error() if restarting: - _launchctl("kickstart", "-k", target()) + stop() + _launchctl("kickstart", target()) elif loaded(): _launchctl("kickstart", target()) else: From 4ff55073a358402ea5369a100bb7c8ced8db55b3 Mon Sep 17 00:00:00 2001 From: lucharo Date: Thu, 23 Jul 2026 18:05:18 +0200 Subject: [PATCH 35/38] fix: make shutdown and readiness ownership-safe --- tests/test_smoke.py | 24 +++++++++++++++++------- v2t/app.py | 2 -- v2t/service.py | 14 +++++++++----- 3 files changed, 26 insertions(+), 14 deletions(-) diff --git a/tests/test_smoke.py b/tests/test_smoke.py index d613db9..65b142c 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -207,13 +207,13 @@ def test_signal_waits_for_the_active_transcription_before_cleanup(self): voice.shutdown() self.assertIsNone(config.running_pid()) - def test_signal_exits_promptly_when_no_transcription_is_active(self): + def test_signal_requests_prompt_shutdown_when_no_transcription_is_active(self): voice = app.VoiceToText(config.Config(cleanup_enabled=False)) voice.instance_lock = config.acquire_instance_lock() - with self.assertRaises(SystemExit): - voice._handle_signal() + voice._handle_signal(signal.SIGTERM) + self.assertTrue(voice.stopping) voice.shutdown() self.assertIsNone(config.running_pid()) @@ -525,8 +525,9 @@ def test_service_start_does_not_restart_a_healthy_process(self): mock.patch.object(service, "plist_path", return_value=plist), mock.patch.object(service, "service_pid", return_value=42), mock.patch.object( - config, "read_status", return_value={"pid": 42, "state": "idle"} + config, "read_status", return_value={"pid": 84, "state": "idle"} ), + mock.patch.object(service, "owned_engine_pid", return_value=84), mock.patch.object(service, "_launchctl") as launchctl, ): service.start() @@ -570,8 +571,8 @@ def test_service_start_waits_until_models_are_ready(self): plist = Path(self.tempdir.name) / "com.lucharo.voice2text.plist" plist.touch() statuses = [ - {"pid": 42, "state": "loading-stt"}, - {"pid": 42, "state": "idle"}, + {"pid": 84, "state": "loading-stt"}, + {"pid": 84, "state": "idle"}, ] with ( mock.patch.object(service, "plist_path", return_value=plist), @@ -584,12 +585,21 @@ def test_service_start_waits_until_models_are_ready(self): self.assertEqual(status.call_count, 2) + def test_service_does_not_accept_an_external_ready_engine(self): + with ( + mock.patch.object( + config, "read_status", return_value={"pid": 84, "state": "idle"} + ), + mock.patch.object(service, "owned_engine_pid", return_value=None), + ): + self.assertFalse(service.engine_ready(42)) + def test_service_start_clears_a_stale_error_before_bootstrap(self): plist = Path(self.tempdir.name) / "com.lucharo.voice2text.plist" plist.touch() with ( mock.patch.object(service, "plist_path", return_value=plist), - mock.patch.object(service, "service_pid", side_effect=[None, 42]), + mock.patch.object(service, "service_pid", side_effect=[None, 42, 42]), mock.patch.object(service.menubar, "running", return_value=False), mock.patch.object(config, "running_pid", return_value=None), mock.patch.object(service, "loaded", return_value=True), diff --git a/v2t/app.py b/v2t/app.py index 66fdec7..42916e2 100644 --- a/v2t/app.py +++ b/v2t/app.py @@ -460,8 +460,6 @@ def _handle_signal(self, signum=None, _frame=None): os.write(self.shutdown_write_fd, b"\0") except OSError: pass - if not self.processing: - raise SystemExit(0) def shutdown(self) -> None: self.stopping = True diff --git a/v2t/service.py b/v2t/service.py index 47a44ff..7d2abe5 100644 --- a/v2t/service.py +++ b/v2t/service.py @@ -55,9 +55,13 @@ def service_pid() -> int | None: return None -def engine_ready() -> bool: +def engine_ready(menu_pid: int | None = None) -> bool: status = config.read_status() - return bool(status and status.get("state") in READY_STATES) + if not status or status.get("state") not in READY_STATES: + return False + if menu_pid is None: + return True + return owned_engine_pid(menu_pid) == status.get("pid") def owned_engine_pid(menu_pid: int) -> int | None: @@ -149,7 +153,7 @@ def start() -> None: raise SystemExit("service is not installed; run: v2t service install") menu_pid = service_pid() if menu_pid is not None: - if engine_ready(): + if engine_ready(menu_pid): return elif menubar.running(): raise SystemExit("quit Voice2Text before starting the login service") @@ -174,11 +178,11 @@ def start() -> None: seen_service = menu_pid is not None and not restarting deadline = time.monotonic() + START_TIMEOUT while time.monotonic() < deadline: - if engine_ready(): + current_service = service_pid() + if current_service is not None and engine_ready(current_service): return if error := config.read_last_error(): raise RuntimeError(error) - current_service = service_pid() if current_service is not None: seen_service = True elif seen_service: From c65ffc0cdefb3b8ca25535fe61c5e017ef376cd2 Mon Sep 17 00:00:00 2001 From: lucharo Date: Thu, 23 Jul 2026 18:09:48 +0200 Subject: [PATCH 36/38] fix: distinguish startup and accepted work --- tests/test_smoke.py | 43 ++++++++++++++++++++++++++++++++++++++++--- v2t/app.py | 37 ++++++++++++++++++++++++++----------- v2t/service.py | 43 +++++++++++++++++++++++++++++-------------- 3 files changed, 95 insertions(+), 28 deletions(-) diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 65b142c..92ea187 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -157,6 +157,9 @@ def test_recording_cannot_restart_before_processing_begins(self): voice.record_start = app.time.perf_counter() voice.frames = [np.ones((2, 1), dtype=np.float32)] voice.stream = mock.Mock() + voice.stream.stop.side_effect = lambda: self.assertTrue( + voice.finalizing_recording + ) with mock.patch.object(app.sd, "InputStream") as input_stream: voice.stop_recording() @@ -210,6 +213,7 @@ def test_signal_waits_for_the_active_transcription_before_cleanup(self): def test_signal_requests_prompt_shutdown_when_no_transcription_is_active(self): voice = app.VoiceToText(config.Config(cleanup_enabled=False)) voice.instance_lock = config.acquire_instance_lock() + voice.startup_complete = True voice._handle_signal(signal.SIGTERM) @@ -217,6 +221,12 @@ def test_signal_requests_prompt_shutdown_when_no_transcription_is_active(self): voice.shutdown() self.assertIsNone(config.running_pid()) + def test_signal_interrupts_model_startup(self): + voice = app.VoiceToText(config.Config(cleanup_enabled=False)) + + with self.assertRaises(SystemExit): + voice._handle_signal(signal.SIGTERM) + def test_run_drains_an_accepted_job_before_shutdown(self): voice = app.VoiceToText(config.Config(cleanup_enabled=False)) @@ -527,7 +537,7 @@ def test_service_start_does_not_restart_a_healthy_process(self): mock.patch.object( config, "read_status", return_value={"pid": 84, "state": "idle"} ), - mock.patch.object(service, "owned_engine_pid", return_value=84), + mock.patch.object(service, "_is_child", return_value=True), mock.patch.object(service, "_launchctl") as launchctl, ): service.start() @@ -578,7 +588,7 @@ def test_service_start_waits_until_models_are_ready(self): mock.patch.object(service, "plist_path", return_value=plist), mock.patch.object(service, "service_pid", return_value=42), mock.patch.object(config, "running_pid", return_value=84), - mock.patch.object(service, "owned_engine_pid", return_value=84), + mock.patch.object(service, "_is_child", return_value=True), mock.patch.object(config, "read_status", side_effect=statuses) as status, ): service.start() @@ -590,10 +600,37 @@ def test_service_does_not_accept_an_external_ready_engine(self): mock.patch.object( config, "read_status", return_value={"pid": 84, "state": "idle"} ), - mock.patch.object(service, "owned_engine_pid", return_value=None), + mock.patch.object(service, "_is_child", return_value=False), ): self.assertFalse(service.engine_ready(42)) + def test_service_start_rejects_an_external_engine(self): + plist = Path(self.tempdir.name) / "com.lucharo.voice2text.plist" + plist.touch() + with ( + mock.patch.object(service, "plist_path", return_value=plist), + mock.patch.object(service, "service_pid", return_value=42), + mock.patch.object(service, "engine_ready", return_value=False), + mock.patch.object(config, "running_pid", return_value=84), + mock.patch.object(service, "_is_child", return_value=False), + ): + with self.assertRaisesRegex(SystemExit, "outside the login service"): + service.start() + + def test_service_status_reports_an_external_engine_honestly(self): + plist = Path(self.tempdir.name) / "com.lucharo.voice2text.plist" + plist.touch() + with ( + mock.patch.object(service, "plist_path", return_value=plist), + mock.patch.object(service, "service_pid", return_value=42), + mock.patch.object(config, "running_pid", return_value=84), + mock.patch.object(service, "_is_child", return_value=False), + ): + self.assertEqual( + service.status(), + "menu running; v2t is running outside the login service", + ) + def test_service_start_clears_a_stale_error_before_bootstrap(self): plist = Path(self.tempdir.name) / "com.lucharo.voice2text.plist" plist.touch() diff --git a/v2t/app.py b/v2t/app.py index 42916e2..7e3dd8a 100644 --- a/v2t/app.py +++ b/v2t/app.py @@ -94,6 +94,8 @@ def __init__(self, cfg: Config): self.shutdown_write_fd = None self.instance_lock = None self.stopping = False + self.finalizing_recording = False + self.startup_complete = False cleanup_model = ( cfg.cleanup_model or backends.CLEANUP[cfg.cleanup_engine].default_model ) @@ -209,16 +211,20 @@ def stop_recording(self): if not self.recording: return self.recording = False - duration = time.perf_counter() - self.record_start - self._close_stream() - logger.info(f"Stopped ({duration:.1f}s)") - if self.frames: - frames, self.frames = self.frames, [] - self.processing = True - self.jobs.put((frames, duration)) - else: - self._restore_media() - self._set_state("idle") + self.finalizing_recording = True + try: + duration = time.perf_counter() - self.record_start + self._close_stream() + logger.info(f"Stopped ({duration:.1f}s)") + if self.frames: + frames, self.frames = self.frames, [] + self.processing = True + self.jobs.put((frames, duration)) + else: + self._restore_media() + self._set_state("idle") + finally: + self.finalizing_recording = False def process_audio(self, frames: list[np.ndarray], audio_s: float): next_state, error_message, temp_path = "idle", "", None @@ -420,6 +426,7 @@ def run(self): signal.signal(signal.SIGINT, self._handle_signal) try: self.warmup() + self.startup_complete = True self._set_state("idle") logger.info(f"Voice-to-Text — {self.cfg.backend} · {self.cfg.mode}") if self.cfg.pause_music: @@ -431,7 +438,9 @@ def run(self): on_press=self.on_press, on_release=self.on_release ) as listener: while listener.is_alive() and ( - not self.stopping or self.processing + not self.stopping + or self.finalizing_recording + or self.processing ): if not self.process_next(timeout=0.25): break @@ -460,6 +469,12 @@ def _handle_signal(self, signum=None, _frame=None): os.write(self.shutdown_write_fd, b"\0") except OSError: pass + if ( + not self.startup_complete + and not self.finalizing_recording + and not self.processing + ): + raise SystemExit(0) def shutdown(self) -> None: self.stopping = True diff --git a/v2t/service.py b/v2t/service.py index 7d2abe5..b6abbad 100644 --- a/v2t/service.py +++ b/v2t/service.py @@ -61,22 +61,26 @@ def engine_ready(menu_pid: int | None = None) -> bool: return False if menu_pid is None: return True - return owned_engine_pid(menu_pid) == status.get("pid") + engine_pid = status.get("pid") + return isinstance(engine_pid, int) and _is_child(engine_pid, menu_pid) + + +def _is_child(pid: int, parent_pid: int) -> bool: + result = subprocess.run( + ["ps", "-o", "ppid=", "-p", str(pid)], + capture_output=True, + text=True, + check=False, + ) + parent = result.stdout.strip() + return parent.isdigit() and int(parent) == parent_pid def owned_engine_pid(menu_pid: int) -> int | None: """Return the live v2t engine only when it is a child of this menu process.""" engine_pid = config.running_pid() - if engine_pid is not None: - result = subprocess.run( - ["ps", "-o", "ppid=", "-p", str(engine_pid)], - capture_output=True, - text=True, - check=False, - ) - parent = result.stdout.strip() - if parent.isdigit() and int(parent) == menu_pid: - return engine_pid + if engine_pid is not None and _is_child(engine_pid, menu_pid): + return engine_pid result = subprocess.run( ["pgrep", "-P", str(menu_pid)], capture_output=True, @@ -155,6 +159,11 @@ def start() -> None: if menu_pid is not None: if engine_ready(menu_pid): return + engine_pid = config.running_pid() + if engine_pid is not None and not _is_child(engine_pid, menu_pid): + raise SystemExit( + "v2t is running outside the login service; stop it first" + ) elif menubar.running(): raise SystemExit("quit Voice2Text before starting the login service") if menu_pid is None and config.running_pid() is not None: @@ -253,8 +262,14 @@ def uninstall() -> None: def status() -> str: if not plist_path().exists(): return "not installed" - if service_pid() is not None: - return "running" if config.running_pid() is not None else "menu running; v2t off" - if config.running_pid() is not None: + menu_pid = service_pid() + engine_pid = config.running_pid() + if menu_pid is not None: + if engine_pid is not None and _is_child(engine_pid, menu_pid): + return "running" + if engine_pid is not None: + return "menu running; v2t is running outside the login service" + return "menu running; v2t off" + if engine_pid is not None: return "installed; v2t is running outside the login service" return "loaded" if loaded() else "installed but unloaded" From 1110ee508d87cf0fcd866d7739dd411c061e29ef Mon Sep 17 00:00:00 2001 From: lucharo Date: Thu, 23 Jul 2026 18:13:55 +0200 Subject: [PATCH 37/38] fix: preserve concurrent clipboard changes --- tests/test_smoke.py | 36 ++++++++++++++++++++++++++++++++++-- v2t/app.py | 28 ++++++++++++++++++---------- 2 files changed, 52 insertions(+), 12 deletions(-) diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 92ea187..352e24f 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -330,7 +330,7 @@ def test_clipboard_is_restored_when_paste_fails(self): original = mock.Mock() original.types.return_value = ["public.png", "public.utf8-plain-text"] original.dataForType_.side_effect = lambda kind: { - "public.png": b"png", + "public.png": b"", "public.utf8-plain-text": b"text", }[kind] pasteboard = mock.Mock() @@ -364,10 +364,42 @@ def test_clipboard_is_restored_when_paste_fails(self): self.assertEqual( [call.args for call in restored_item.setData_forType_.call_args_list], - [(b"png", "public.png"), (b"text", "public.utf8-plain-text")], + [(b"", "public.png"), (b"text", "public.utf8-plain-text")], ) pasteboard.writeObjects_.assert_called_once_with([restored_item]) + def test_clipboard_is_not_restored_over_a_new_user_copy(self): + voice = app.VoiceToText(config.Config(cleanup_enabled=False)) + pasteboard = mock.Mock() + pasteboard.pasteboardItems.return_value = [] + pasteboard.setString_forType_.return_value = True + pasteboard.changeCount.side_effect = [10, 11] + fake_appkit = types.SimpleNamespace( + NSPasteboard=mock.Mock( + generalPasteboard=mock.Mock(return_value=pasteboard) + ), + NSPasteboardItem=mock.Mock(), + NSPasteboardTypeString="public.utf8-plain-text", + ) + fake_quartz = types.SimpleNamespace( + CGEventCreateKeyboardEvent=lambda *_args: object(), + CGEventPost=lambda *_args: None, + CGEventSetFlags=lambda *_args: None, + kCGEventFlagMaskCommand=1, + kCGHIDEventTap=0, + ) + + with ( + mock.patch.dict( + sys.modules, {"AppKit": fake_appkit, "Quartz": fake_quartz} + ), + mock.patch.object(app.time, "sleep"), + ): + voice.paste_to_cursor("dictated") + + pasteboard.clearContents.assert_called_once() + pasteboard.writeObjects_.assert_not_called() + def test_history_is_private_and_valid_jsonl(self): config.append_history({"raw": "hello", "clean": "Hello."}) diff --git a/v2t/app.py b/v2t/app.py index 7e3dd8a..3e483a8 100644 --- a/v2t/app.py +++ b/v2t/app.py @@ -340,13 +340,16 @@ def paste_to_cursor(self, text: str) -> None: for item in pasteboard.pasteboardItems() or []: values = [] for kind in item.types(): - if data := item.dataForType_(kind): + data = item.dataForType_(kind) + if data is not None: values.append((kind, data)) saved.append(values) + dictated_change = None try: pasteboard.clearContents() if not pasteboard.setString_forType_(text, NSPasteboardTypeString): raise RuntimeError("could not write to the clipboard") + dictated_change = pasteboard.changeCount() down = CGEventCreateKeyboardEvent(None, 9, True) up = CGEventCreateKeyboardEvent(None, 9, False) CGEventSetFlags(down, kCGEventFlagMaskCommand) @@ -356,15 +359,20 @@ def paste_to_cursor(self, text: str) -> None: # Give slower targets time to consume the synthetic paste before restoration. time.sleep(0.3) finally: - pasteboard.clearContents() - restored = [] - for values in saved: - item = NSPasteboardItem.alloc().init() - for kind, data in values: - item.setData_forType_(data, kind) - restored.append(item) - if restored: - pasteboard.writeObjects_(restored) + should_restore = ( + dictated_change is None + or pasteboard.changeCount() == dictated_change + ) + if should_restore: + pasteboard.clearContents() + restored = [] + for values in saved: + item = NSPasteboardItem.alloc().init() + for kind, data in values: + item.setData_forType_(data, kind) + restored.append(item) + if restored: + pasteboard.writeObjects_(restored) # --- run loop ----------------------------------------------------------- def on_press(self, key): From 445b68bf1b9434499cf599ae20aeb815f42392ef Mon Sep 17 00:00:00 2001 From: lucharo Date: Thu, 23 Jul 2026 18:21:50 +0200 Subject: [PATCH 38/38] fix: synchronize shutdown and clipboard snapshot --- tests/test_smoke.py | 32 +++++++++++++++++++++++++++++++- v2t/app.py | 38 +++++++++++++++++++++++++------------- 2 files changed, 56 insertions(+), 14 deletions(-) diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 352e24f..0dd6bd8 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -373,7 +373,7 @@ def test_clipboard_is_not_restored_over_a_new_user_copy(self): pasteboard = mock.Mock() pasteboard.pasteboardItems.return_value = [] pasteboard.setString_forType_.return_value = True - pasteboard.changeCount.side_effect = [10, 11] + pasteboard.changeCount.side_effect = [9, 9, 10, 11] fake_appkit = types.SimpleNamespace( NSPasteboard=mock.Mock( generalPasteboard=mock.Mock(return_value=pasteboard) @@ -400,6 +400,36 @@ def test_clipboard_is_not_restored_over_a_new_user_copy(self): pasteboard.clearContents.assert_called_once() pasteboard.writeObjects_.assert_not_called() + def test_paste_aborts_before_clear_if_clipboard_snapshot_keeps_changing(self): + voice = app.VoiceToText(config.Config(cleanup_enabled=False)) + pasteboard = mock.Mock() + pasteboard.pasteboardItems.return_value = [] + pasteboard.changeCount.side_effect = [1, 2, 3, 4] + fake_appkit = types.SimpleNamespace( + NSPasteboard=mock.Mock( + generalPasteboard=mock.Mock(return_value=pasteboard) + ), + NSPasteboardItem=mock.Mock(), + NSPasteboardTypeString="public.utf8-plain-text", + ) + fake_quartz = types.SimpleNamespace( + CGEventCreateKeyboardEvent=lambda *_args: object(), + CGEventPost=lambda *_args: None, + CGEventSetFlags=lambda *_args: None, + kCGEventFlagMaskCommand=1, + kCGHIDEventTap=0, + ) + + with ( + mock.patch.dict( + sys.modules, {"AppKit": fake_appkit, "Quartz": fake_quartz} + ), + self.assertRaisesRegex(RuntimeError, "clipboard changed"), + ): + voice.paste_to_cursor("dictated") + + pasteboard.clearContents.assert_not_called() + def test_history_is_private_and_valid_jsonl(self): config.append_history({"raw": "hello", "clean": "Hello."}) diff --git a/v2t/app.py b/v2t/app.py index 3e483a8..04def87 100644 --- a/v2t/app.py +++ b/v2t/app.py @@ -324,6 +324,14 @@ def process_next(self, timeout: float | None = None) -> bool: self.process_audio(*job) return True + def _keep_running(self) -> bool: + with self.lifecycle_lock: + return ( + not self.stopping + or self.finalizing_recording + or self.processing + ) + def paste_to_cursor(self, text: str) -> None: """Paste at the cursor, preserving every native pasteboard representation.""" from AppKit import NSPasteboard, NSPasteboardItem, NSPasteboardTypeString @@ -336,14 +344,22 @@ def paste_to_cursor(self, text: str) -> None: ) pasteboard = NSPasteboard.generalPasteboard() - saved = [] - for item in pasteboard.pasteboardItems() or []: - values = [] - for kind in item.types(): - data = item.dataForType_(kind) - if data is not None: - values.append((kind, data)) - saved.append(values) + saved = None + for _ in range(2): + snapshot_change = pasteboard.changeCount() + snapshot = [] + for item in pasteboard.pasteboardItems() or []: + values = [] + for kind in item.types(): + data = item.dataForType_(kind) + if data is not None: + values.append((kind, data)) + snapshot.append(values) + if pasteboard.changeCount() == snapshot_change: + saved = snapshot + break + if saved is None: + raise RuntimeError("clipboard changed while preparing paste") dictated_change = None try: pasteboard.clearContents() @@ -445,11 +461,7 @@ def run(self): with keyboard.Listener( on_press=self.on_press, on_release=self.on_release ) as listener: - while listener.is_alive() and ( - not self.stopping - or self.finalizing_recording - or self.processing - ): + while listener.is_alive() and self._keep_running(): if not self.process_next(timeout=0.25): break if not self.stopping and not listener.is_alive():