wersperr/wersper.py
type-two bf70263d2a wersper: local push-to-talk dictation (MLX Whisper, one file)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 21:50:52 +10:00

100 lines
2.4 KiB
Python

# /// 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()