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:
parent
bf70263d2a
commit
1419baef20
@ -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.
|
||||||
|
|||||||
41
wersper.py
41
wersper.py
@ -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()
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user