wersper: local push-to-talk dictation (MLX Whisper, one file)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
bf70263d2a
31
README.md
Normal file
31
README.md
Normal file
@ -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`.
|
||||||
99
wersper.py
Normal file
99
wersper.py
Normal file
@ -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()
|
||||||
Loading…
Reference in New Issue
Block a user