Switch to mlx-whisper for Apple Silicon acceleration - #11
Conversation
…transcription - Replace faster-whisper with mlx-whisper for native Apple Silicon (MLX/Metal) inference - Eliminate temp WAV file I/O by passing audio buffer directly as numpy array - Add chunked transcription that processes audio every 5s during recording, so text appears near-instantly when recording stops - Add pipeline metrics logging (transcription time, RTF, total latency) - Warm up model on startup to avoid cold-start penalty on first transcription - Fix stray character prepended to transcription by adding a small delay before typing to let key events fully propagate Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR switches the dictation backend from faster-whisper to mlx-whisper to leverage Apple Silicon (MLX/Metal) acceleration, removes temp WAV file I/O by transcribing directly from a NumPy buffer, and introduces periodic chunked transcription plus detailed pipeline metrics logging.
Changes:
- Replace
faster-whisperfile-based transcription withmlx-whisperNumPy-array transcription (incl. model warm-up). - Add a chunked transcription loop during recording to cache partial results and reduce final wait time.
- Add
[METRICS]logging for model load, buffer conversion, transcription, enhancement, insertion, and total pipeline latency.
Reviewed changes
Copilot reviewed 1 out of 2 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
src/main.py |
Switches transcription to mlx_whisper, adds chunked transcription + metrics, and adjusts insertion timing. |
requirements.txt |
Replaces faster-whisper dependency with mlx-whisper. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if hasattr(self, 'recording_thread') and self.recording_thread.is_alive(): | ||
| self.recording_thread.join(timeout=1.0) | ||
|
|
There was a problem hiding this comment.
cleanup() stops the recording thread but does not signal/join the chunked transcription thread. If the app exits while recording, chunk_thread can keep running and prevent a clean shutdown (or force the watchdog to os._exit). Consider setting chunked_stop in cleanup() and joining chunk_thread (with a bounded timeout), similar to stop_recording()/discard_recording().
| # Stop chunked transcription thread if it is running | |
| if hasattr(self, 'chunked_stop'): | |
| self.chunked_stop.set() | |
| if hasattr(self, 'chunk_thread') and self.chunk_thread.is_alive(): | |
| self.chunk_thread.join(timeout=1.0) |
| self.chunked_stop.set() | ||
| if hasattr(self, 'chunk_thread') and self.chunk_thread.is_alive(): | ||
| self.chunk_thread.join(timeout=2.0) | ||
| self.frames = [] |
There was a problem hiding this comment.
discard_recording() joins recording_thread with a timeout and then immediately reassigns self.frames = []. If recording_thread is still alive, it can append to the new list after discard, leaving unexpected audio buffered and potentially confusing later transcriptions. Consider waiting until recording_thread fully exits (or guarding frame appends with a lock / local reference) before resetting frames.
| from pynput import keyboard | ||
| from pynput.keyboard import Key, Controller | ||
| import faster_whisper | ||
| import mlx_whisper | ||
| import signal | ||
| from text_selection import TextSelection |
There was a problem hiding this comment.
The code now imports/depends on mlx_whisper, which (per MLX) only works on Apple Silicon. On Intel macOS this will fail at import time and prevent the app from starting. Consider adding an explicit runtime architecture check with a clear status/error message (and/or a fallback backend) so users get an actionable explanation instead of a crash.
| # Stop chunked transcription and wait for it to finish | ||
| self.chunked_stop.set() | ||
| if hasattr(self, 'chunk_thread'): | ||
| self.chunk_thread.join(timeout=5.0) |
There was a problem hiding this comment.
stop_recording() joins chunk_thread with a 5s timeout but doesn’t verify that it actually stopped before starting process_recording(). If chunked_transcribe_loop is in the middle of a long mlx_whisper.transcribe call, it may keep running concurrently with the final transcription, causing concurrent model executions and races on partial_text/last_transcribed_frame_count. Consider joining without timeout, or (if you must bound it) checking is_alive() and skipping cached/partial usage until the thread has fully exited.
| # Stop chunked transcription and wait for it to finish | |
| self.chunked_stop.set() | |
| if hasattr(self, 'chunk_thread'): | |
| self.chunk_thread.join(timeout=5.0) | |
| # Stop chunked transcription and wait for it to fully finish | |
| # before starting the final transcription pass. A timed join can | |
| # return while the thread is still inside a long-running transcribe | |
| # call, which would allow concurrent model execution and races on | |
| # shared transcription state. | |
| self.chunked_stop.set() | |
| if hasattr(self, 'chunk_thread'): | |
| self.chunk_thread.join() |
| # Stop chunked transcription if running | ||
| self.chunked_stop.set() | ||
| if hasattr(self, 'chunk_thread') and self.chunk_thread.is_alive(): | ||
| self.chunk_thread.join(timeout=2.0) | ||
| self.frames = [] |
There was a problem hiding this comment.
discard_recording() joins chunk_thread with a 2s timeout and then clears frames/UI state. If chunked_transcribe_loop is still running (e.g., stuck in mlx_whisper.transcribe), it can continue consuming resources and mutate partial_text/last_transcribed_frame_count after the recording is discarded. Consider ensuring the thread has actually exited (or making the transcription call cancellable / running it in a separate process) before resetting state.
- Add Apple Silicon (arm64) check at startup with clear error message - Stop chunked transcription thread in cleanup() for clean shutdown - Remove timeouts on chunk_thread.join() in stop_recording() and discard_recording() to prevent concurrent model execution races - Wait for recording thread to fully exit in discard_recording() before clearing frames to prevent race condition Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Skip Cmd+C selected-text check when Bedrock is unavailable (common path), and add timing guards around keyboard modifier sequences to prevent key events leaking as literal characters in Electron apps. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The Cmd+C simulation in get_selected_text() leaks a literal 'c' into the target app. Since boto3 client init succeeds whenever AWS creds exist (common for Amazon employees), the previous is_available() guard was insufficient. Now the Cmd+C check only runs when the user explicitly opts in via ENABLE_TEXT_ENHANCEMENT=true. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Switch from whisper-medium.en to distil-whisper-large-v3 for better accuracy. Add language="en" to skip auto-detection, initial_prompt with domain vocabulary for software engineering terms, and disable condition_on_previous_text for chunked transcription to prevent error propagation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Prevent crashes on long audio by adding thread-safe frame buffer access, stream cleanup guarantees, join timeouts, and division-by-zero guards. Add persistent file logging for crash diagnostics. Include OTEL/OpenTelemetry in whisper initial prompt to fix misrecognition as "hotel". Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Summary
Performance Results
Test plan
[METRICS]log lines for latency tracking🤖 Generated with Claude Code