diff --git a/README.md b/README.md index 79751f2..774c7a8 100644 --- a/README.md +++ b/README.md @@ -27,5 +27,8 @@ 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`. +- **Esc** while recording cancels the take. +- Pop = recording started, Tink = text pasted. +- Your clipboard is restored after pasting (text only — images are lost). +- Hotkey, model, language, and silence threshold are constants at the top of + `wersper.py`. Set `LANGUAGE = None` for autodetect. diff --git a/wersper.py b/wersper.py index df31f3c..ed1cbd0 100644 --- a/wersper.py +++ b/wersper.py @@ -5,10 +5,12 @@ """wersper — hold Right Option, speak, release. Text appears at your cursor. Run: uv run wersper.py +Esc while recording cancels the take. """ import subprocess import sys import threading +import time import numpy as np import sounddevice as sd @@ -16,23 +18,35 @@ import mlx_whisper from pynput import keyboard MODEL = "mlx-community/whisper-large-v3-turbo" +LANGUAGE = "en" # None = autodetect (slower, occasionally wrong) HOTKEY = keyboard.Key.alt_r SAMPLE_RATE = 16000 +# ponytail: crude silence gate; raise if background noise gets transcribed, +# lower if it eats quiet speech +MIN_RMS = 0.001 frames = [] stream = None kb = keyboard.Controller() +def sound(name): + subprocess.Popen(["afplay", f"/System/Library/Sounds/{name}.aiff"]) + + def transcribe(audio): - return mlx_whisper.transcribe(audio, path_or_hf_repo=MODEL)["text"].strip() + result = mlx_whisper.transcribe(audio, path_or_hf_repo=MODEL, language=LANGUAGE) + return result["text"].strip() def paste(text): + old = subprocess.run("pbpaste", capture_output=True).stdout subprocess.run("pbcopy", input=text.encode(), check=True) with kb.pressed(keyboard.Key.cmd): kb.press("v") kb.release("v") + time.sleep(0.4) # let the paste land before giving the clipboard back + subprocess.run("pbcopy", input=old) # ponytail: restores text only, not images def start_recording(): @@ -45,35 +59,46 @@ def start_recording(): callback=lambda data, *_: frames.append(data.copy()), ) stream.start() - print("● recording...", flush=True) + sound("Pop") + print("● recording... (Esc to cancel)", flush=True) -def stop_recording(): +def stop_recording(cancel=False): global stream if not stream: return stream.stop() stream.close() stream = None + if cancel: + print("✕ cancelled", flush=True) + return if not frames: return audio = np.concatenate(frames).flatten() if len(audio) < SAMPLE_RATE // 3: # ignore accidental taps return + if np.sqrt((audio ** 2).mean()) < MIN_RMS: + print("(silence, skipped)", flush=True) + return # ponytail: transcribe in a thread so the hotkey stays responsive threading.Thread(target=lambda: finish(audio), daemon=True).start() def finish(audio): + t0 = time.time() text = transcribe(audio) if text: - print(f"→ {text}", flush=True) + print(f"→ {text} ({time.time() - t0:.1f}s)", flush=True) paste(text) + sound("Tink") def on_press(key): if key == HOTKEY: start_recording() + elif key == keyboard.Key.esc and stream: + stop_recording(cancel=True) def on_release(key): @@ -82,9 +107,9 @@ def on_release(key): def check(): - """Self-check: model loads and transcribes silence without exploding.""" - result = transcribe(np.zeros(SAMPLE_RATE, dtype=np.float32)) - assert isinstance(result, str) + """Self-check: model loads, pipeline works, silence gate works.""" + assert isinstance(transcribe(np.zeros(SAMPLE_RATE, dtype=np.float32)), str) + assert np.sqrt((np.zeros(SAMPLE_RATE) ** 2).mean()) < MIN_RMS print("ok: model loaded, pipeline works") @@ -94,6 +119,6 @@ if __name__ == "__main__": 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) + print("ready — hold Right ⌥ to dictate, Esc cancels, Ctrl+C quits", flush=True) with keyboard.Listener(on_press=on_press, on_release=on_release) as listener: listener.join()