Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
0dbdbb6
v0.3.0: modular MLX-first package (Parakeet + Qwen3, config, SwiftBar…
claude Jun 16, 2026
475be68
Add cross-machine benchmark grid scaffold; drop redundant import
claude Jun 16, 2026
1263ae2
Cleanup via in-process mlx-lm by default; add guided 'v2t setup'
claude Jun 16, 2026
218e0cd
Flip packaging: Parakeet + mlx-lm in core, bare install just works
lucharo Jun 16, 2026
9b6ee24
Benchmark on M1 Pro: warm up STT before timing; record results + quality
lucharo Jun 16, 2026
3fb7e79
SwiftBar: live state icon + instant feedback; ponytail cuts
lucharo Jun 17, 2026
b031eaf
Revert .gitignore band-aid: ponytail skills live at user level (~/.ag…
lucharo Jun 17, 2026
b2f3b8c
SwiftBar: permissions submenu, models line; fix silent-mic + history dir
lucharo Jun 17, 2026
cb333a5
fix: harden v0.3 runtime and packaging
lucharo Jul 20, 2026
b70da83
fix: avoid crashing SwiftBar refresh URLs
lucharo Jul 20, 2026
1410921
feat: clarify SwiftBar startup and permissions
lucharo Jul 20, 2026
b3f30f8
fix: flatten SwiftBar permission controls
lucharo Jul 20, 2026
b726e31
feat: replace SwiftBar with native menu
lucharo Jul 20, 2026
a1bb8ec
fix: remove redundant input monitoring gate
lucharo Jul 20, 2026
722bfe6
fix: harden native service startup
lucharo Jul 20, 2026
183f287
fix: keep MLX inference on its loading thread
lucharo Jul 20, 2026
121463f
perf: skip Hub validation for cached Parakeet
lucharo Jul 20, 2026
d50be72
Revert "perf: skip Hub validation for cached Parakeet"
lucharo Jul 20, 2026
e45f700
perf: reduce local dictation memory
lucharo Jul 22, 2026
5575037
ci: install PortAudio for Linux smoke tests
lucharo Jul 23, 2026
0900629
fix: make shutdown and startup readiness honest
lucharo Jul 23, 2026
dcdc373
fix: close shutdown races
lucharo Jul 23, 2026
a48bfea
fix: keep signal handling reentrant
lucharo Jul 23, 2026
fab1dde
fix: serialize engine lifecycle
lucharo Jul 23, 2026
74daf47
fix: publish graceful shutdown state
lucharo Jul 23, 2026
68580f3
fix: make shutdown notification efficient
lucharo Jul 23, 2026
7eca4d8
fix: wait for engine before menu quit
lucharo Jul 23, 2026
b05fade
fix: wait for service shutdown
lucharo Jul 23, 2026
6a1099e
fix: stop only the service-owned engine
lucharo Jul 23, 2026
a6dbc77
fix: make service stop idempotent
lucharo Jul 23, 2026
d61b5b2
fix: keep graceful shutdown idempotent
lucharo Jul 23, 2026
c6270e0
fix: bound service shutdown escalation
lucharo Jul 23, 2026
67c259b
fix: preserve launchd child during startup
lucharo Jul 23, 2026
c0e49ae
fix: drain accepted jobs before shutdown
lucharo Jul 23, 2026
4ff5507
fix: make shutdown and readiness ownership-safe
lucharo Jul 23, 2026
c65ffc0
fix: distinguish startup and accepted work
lucharo Jul 23, 2026
1110ee5
fix: preserve concurrent clipboard changes
lucharo Jul 23, 2026
445b68b
fix: synchronize shutdown and clipboard snapshot
lucharo Jul 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
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: sudo apt-get install -y libportaudio2
- run: uv lock --check
- run: uvx ruff check v2t tests
- 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/*

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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,7 @@ dist/
!.pixi/config.toml

.env

# Local agent handoff state
.continues-handoff.md
.omo/
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,26 @@
# 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. 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** (`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`.
- **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 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 menubar`.

## 0.2.0

- Warm up both Whisper and Ollama models at startup so the first transcription is fast
Expand Down
202 changes: 137 additions & 65 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,116 +3,188 @@
[![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 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 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. 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.

> **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 an optional one-file menu-bar app.

> **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!
> **Design tenet — be communicative.** An ergonomic tool is an expressive one: every action gets
> 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.

## Prerequisites
## What you get

> 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.
- **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.

## 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). 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
uvx --from voice2text v2t
uv tool install voice2text # Parakeet STT + in-process Qwen2.5 cleanup
v2t setup # optional: pick models, detect Ollama, write config
v2t
```

Or from GitHub:
Prefer Whisper for transcription? Add the extra (quote the brackets — zsh treats them as globs):

```bash
uvx --from git+https://github.com/lucharo/voice2text v2t
uv tool install 'voice2text[whisper]' # adds the Whisper backend; select it in config
```

### uv tool install (recommended for daily use)

Installs `v2t` as a persistent command — no virtual environment setup on each run, so startup is fast.
<details>
<summary>Other install methods (uvx, pip, dev)</summary>

```bash
uv tool install voice2text
v2t
```
# quick try (fresh venv each run — slower startup)
uvx --from voice2text v2t

### pip
# pip
pip install voice2text && v2t

```bash
pip install voice2text
v2t
# from source
git clone https://github.com/lucharo/voice2text.git && cd voice2text
uv sync --no-dev && uv run v2t
```
</details>

### Development install
## Usage

```bash
git clone https://github.com/lucharo/voice2text.git
cd voice2text
uv sync
uv run v2t
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 setup # guided config: pick models, detect Ollama
v2t status # running / idle (also used by the menu app)
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
```

### Pixi
Hold **Right Command** to record, release to transcribe and paste.

Pixi handles the ollama dependency automatically:
### Strict vs Casual

```bash
git clone https://github.com/lucharo/voice2text.git
cd voice2text
pixi run ollama pull qwen2.5:3b
pixi run v2t
| 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.

## 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/ # private runtime status + log
```

> **Note:** We don't publish to conda-forge/pixi channels yet, but may in the future.
`config.toml` (every key optional — these are the defaults):

## Usage
```toml
[transcription]
backend = "parakeet" # parakeet (MLX) | whisper
model = "" # blank = backend default

```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)
[cleanup]
enabled = true
engine = "mlx" # mlx (in-process via mlx-lm) | ollama
model = "" # blank = engine default
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
```

Hold **Right Command** to record, release to transcribe and paste.
### Cleanup engine

### Strict vs Casual Mode
Cleanup runs **in-process via [mlx-lm](https://github.com/ml-explore/mlx-lm)** by default
(`Qwen2.5-1.5B-Instruct`, non-thinking) — no daemon, no HTTP, same MLX stack as transcription.

| 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." |
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
```

**Strict** (default): Removes filler words, restructures for clarity, condenses.
Either way, use a **non-thinking** model — a model that emits `<think>` blocks will paste its
reasoning. The defaults don't.

**Casual**: Only adds punctuation and removes "um/uh", keeps your phrasing.
## Optional menu-bar app

```bash
v2t menubar install # compile the bundled Swift file into ~/Applications/Voice2Text.app
```

### `--pause-music` (macOS only)
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 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.

Pauses any playing media while recording and resumes after. Requires:
**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
brew install nowplaying-cli
v2t service install # install + start ~/Library/LaunchAgents/com.lucharo.voice2text.plist
v2t service status
v2t service uninstall
```

Not available via pixi/conda-forge for now, maybe will publish later!
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) 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

The contributor-only `just bench` harness writes `~/.v2t/benchmarks/results/<date>-<host>.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 | `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.
61 changes: 61 additions & 0 deletions benchmarks/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# 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 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
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
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

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
```

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
`~/.v2t/benchmarks/results/<date>-<host>.md`; copy results into this directory when contributing
to 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) | `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 `<think>…</think>` 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 generation (mlx-lm or the Ollama HTTP API) and timing the first token.

See `results/` for collected runs.
Empty file added benchmarks/results/.gitkeep
Empty file.
Loading