polish: audio cues, Esc cancel, clipboard restore, silence gate, fixed language

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-17 17:43:07 +10:00
parent bf70263d2a
commit 1419baef20
2 changed files with 38 additions and 10 deletions

View File

@ -27,5 +27,8 @@ uv run wersper.py --check
## Notes ## Notes
- Pasting overwrites your clipboard with the transcription. - **Esc** while recording cancels the take.
- To change the hotkey or model, edit the constants at the top of `wersper.py`. - 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.

View File

@ -5,10 +5,12 @@
"""wersper — hold Right Option, speak, release. Text appears at your cursor. """wersper — hold Right Option, speak, release. Text appears at your cursor.
Run: uv run wersper.py Run: uv run wersper.py
Esc while recording cancels the take.
""" """
import subprocess import subprocess
import sys import sys
import threading import threading
import time
import numpy as np import numpy as np
import sounddevice as sd import sounddevice as sd
@ -16,23 +18,35 @@ import mlx_whisper
from pynput import keyboard from pynput import keyboard
MODEL = "mlx-community/whisper-large-v3-turbo" MODEL = "mlx-community/whisper-large-v3-turbo"
LANGUAGE = "en" # None = autodetect (slower, occasionally wrong)
HOTKEY = keyboard.Key.alt_r HOTKEY = keyboard.Key.alt_r
SAMPLE_RATE = 16000 SAMPLE_RATE = 16000
# ponytail: crude silence gate; raise if background noise gets transcribed,
# lower if it eats quiet speech
MIN_RMS = 0.001
frames = [] frames = []
stream = None stream = None
kb = keyboard.Controller() kb = keyboard.Controller()
def sound(name):
subprocess.Popen(["afplay", f"/System/Library/Sounds/{name}.aiff"])
def transcribe(audio): 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): def paste(text):
old = subprocess.run("pbpaste", capture_output=True).stdout
subprocess.run("pbcopy", input=text.encode(), check=True) subprocess.run("pbcopy", input=text.encode(), check=True)
with kb.pressed(keyboard.Key.cmd): with kb.pressed(keyboard.Key.cmd):
kb.press("v") kb.press("v")
kb.release("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(): def start_recording():
@ -45,35 +59,46 @@ def start_recording():
callback=lambda data, *_: frames.append(data.copy()), callback=lambda data, *_: frames.append(data.copy()),
) )
stream.start() 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 global stream
if not stream: if not stream:
return return
stream.stop() stream.stop()
stream.close() stream.close()
stream = None stream = None
if cancel:
print("✕ cancelled", flush=True)
return
if not frames: if not frames:
return return
audio = np.concatenate(frames).flatten() audio = np.concatenate(frames).flatten()
if len(audio) < SAMPLE_RATE // 3: # ignore accidental taps if len(audio) < SAMPLE_RATE // 3: # ignore accidental taps
return 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 # ponytail: transcribe in a thread so the hotkey stays responsive
threading.Thread(target=lambda: finish(audio), daemon=True).start() threading.Thread(target=lambda: finish(audio), daemon=True).start()
def finish(audio): def finish(audio):
t0 = time.time()
text = transcribe(audio) text = transcribe(audio)
if text: if text:
print(f"{text}", flush=True) print(f"{text} ({time.time() - t0:.1f}s)", flush=True)
paste(text) paste(text)
sound("Tink")
def on_press(key): def on_press(key):
if key == HOTKEY: if key == HOTKEY:
start_recording() start_recording()
elif key == keyboard.Key.esc and stream:
stop_recording(cancel=True)
def on_release(key): def on_release(key):
@ -82,9 +107,9 @@ def on_release(key):
def check(): def check():
"""Self-check: model loads and transcribes silence without exploding.""" """Self-check: model loads, pipeline works, silence gate works."""
result = transcribe(np.zeros(SAMPLE_RATE, dtype=np.float32)) assert isinstance(transcribe(np.zeros(SAMPLE_RATE, dtype=np.float32)), str)
assert isinstance(result, str) assert np.sqrt((np.zeros(SAMPLE_RATE) ** 2).mean()) < MIN_RMS
print("ok: model loaded, pipeline works") print("ok: model loaded, pipeline works")
@ -94,6 +119,6 @@ if __name__ == "__main__":
sys.exit(0) sys.exit(0)
print("loading model (first run downloads ~1.6GB)...", flush=True) print("loading model (first run downloads ~1.6GB)...", flush=True)
transcribe(np.zeros(SAMPLE_RATE, dtype=np.float32)) # warm up 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: with keyboard.Listener(on_press=on_press, on_release=on_release) as listener:
listener.join() listener.join()