From bf70263d2ae51ffc510d1b66aa44249f7abc8c36 Mon Sep 17 00:00:00 2001 From: type-two Date: Mon, 6 Jul 2026 21:50:52 +1000 Subject: [PATCH] wersper: local push-to-talk dictation (MLX Whisper, one file) Co-Authored-By: Claude Fable 5 --- README.md | 31 +++++++++++++++++ wersper.py | 99 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 README.md create mode 100644 wersper.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..79751f2 --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# wersper + +Private, self-owned Wispr Flow. Hold **Right ⌥**, speak, release — the +transcription is pasted at your cursor. Everything runs locally on your Mac +(Whisper large-v3-turbo via MLX on the GPU). No cloud, no accounts, no telemetry. + +## Run + +```sh +uv run wersper.py +``` + +First run downloads the model (~1.6GB from Hugging Face) and installs deps. + +## macOS permissions (one-time) + +Grant your terminal app both of these in **System Settings → Privacy & Security**: + +- **Microphone** — to record you +- **Accessibility** — to paste into other apps (simulated ⌘V) + +## Self-check + +```sh +uv run wersper.py --check +``` + +## Notes + +- Pasting overwrites your clipboard with the transcription. +- To change the hotkey or model, edit the constants at the top of `wersper.py`. diff --git a/wersper.py b/wersper.py new file mode 100644 index 0000000..df31f3c --- /dev/null +++ b/wersper.py @@ -0,0 +1,99 @@ +# /// script +# requires-python = ">=3.11,<3.13" +# dependencies = ["mlx-whisper", "sounddevice", "pynput", "numpy"] +# /// +"""wersper — hold Right Option, speak, release. Text appears at your cursor. + +Run: uv run wersper.py +""" +import subprocess +import sys +import threading + +import numpy as np +import sounddevice as sd +import mlx_whisper +from pynput import keyboard + +MODEL = "mlx-community/whisper-large-v3-turbo" +HOTKEY = keyboard.Key.alt_r +SAMPLE_RATE = 16000 + +frames = [] +stream = None +kb = keyboard.Controller() + + +def transcribe(audio): + return mlx_whisper.transcribe(audio, path_or_hf_repo=MODEL)["text"].strip() + + +def paste(text): + subprocess.run("pbcopy", input=text.encode(), check=True) + with kb.pressed(keyboard.Key.cmd): + kb.press("v") + kb.release("v") + + +def start_recording(): + global stream + if stream: + return + frames.clear() + stream = sd.InputStream( + samplerate=SAMPLE_RATE, channels=1, dtype="float32", + callback=lambda data, *_: frames.append(data.copy()), + ) + stream.start() + print("● recording...", flush=True) + + +def stop_recording(): + global stream + if not stream: + return + stream.stop() + stream.close() + stream = None + if not frames: + return + audio = np.concatenate(frames).flatten() + if len(audio) < SAMPLE_RATE // 3: # ignore accidental taps + return + # ponytail: transcribe in a thread so the hotkey stays responsive + threading.Thread(target=lambda: finish(audio), daemon=True).start() + + +def finish(audio): + text = transcribe(audio) + if text: + print(f"→ {text}", flush=True) + paste(text) + + +def on_press(key): + if key == HOTKEY: + start_recording() + + +def on_release(key): + if key == HOTKEY: + stop_recording() + + +def check(): + """Self-check: model loads and transcribes silence without exploding.""" + result = transcribe(np.zeros(SAMPLE_RATE, dtype=np.float32)) + assert isinstance(result, str) + print("ok: model loaded, pipeline works") + + +if __name__ == "__main__": + if "--check" in sys.argv: + check() + sys.exit(0) + print("loading model (first run downloads ~1.6GB)...", flush=True) + transcribe(np.zeros(SAMPLE_RATE, dtype=np.float32)) # warm up + print(f"ready — hold Right ⌥ to dictate, Ctrl+C to quit", flush=True) + with keyboard.Listener(on_press=on_press, on_release=on_release) as listener: + listener.join()