
Porting Apex Voice to Windows broke at every single layer (faster-whisper + pystray + pywin32)
I ported my macOS voice-typing app Apex Voice to Windows, aiming for feature parity. Every layer of the stack tripped me up. Here's the honest log.
Where I started
Apex Voice is the macOS
menu-bar voice-typing app I built on mlx-whisper + rumps + LaunchAgent.
Apple Silicon-only by design, so it doesn't run on Windows as-is.
menu-bar voice-typing app I built on mlx-whisper + rumps + LaunchAgent.
Apple Silicon-only by design, so it doesn't run on Windows as-is.
The plan was a port for parity. The only test environment I had on
hand was Windows 11 ARM64 (evaluation) on UTM. I did not expect
this to be a months-of-effort kind of project. It wasn't months, but
it was every layer.
hand was Windows 11 ARM64 (evaluation) on UTM. I did not expect
this to be a months-of-effort kind of project. It wasn't months, but
it was every layer.
- Repo: github.com/yama3133/apex-voice
- Windows README: README_win.md

The stack I picked
The substitutions I started with:
| macOS | Windows | |
|---|---|---|
| Speech recognition | mlx-whisper | faster-whisper (CPU/int8) |
| Tray | rumps | pystray |
| Text insertion | NSPasteboard + osascript Cmd+V | pyperclip + pynput Ctrl+V |
| Auto-launch | LaunchAgent | Task Scheduler |
| Caps Lock one-key | Karabiner-Elements | AutoHotkey v2 |
In my head this was "swap the libraries." In reality, every swapped
layer broke in its own way.
layer broke in its own way.
Pain #1: sounddevice doesn't load on ARM64
First blocker. I wanted to use sounddevice for mic input, but on
ARM64 Windows it fails with
PortAudio doesn't ship an ARM64 binary.
ARM64 Windows it fails with
libportaudioarm64.dll: error 0x7e.PortAudio doesn't ship an ARM64 binary.
→ Fall back to pyaudio. pyaudio has an ARM64 wheel and just works.
Rewrote the Recorder class around it.
Rewrote the Recorder class around it.
1
2
3
4
5
6
7
8
9
10
class Recorder:
"""sounddevice doesn't work on ARM64 Windows; use pyaudio."""
def _start_pyaudio(self):
import pyaudio
self._pa = pyaudio.PyAudio()
self._stream = self._pa.open(
rate=SAMPLE_RATE, channels=1, format=pyaudio.paFloat32,
input=True, frames_per_buffer=BLOCK
)Pain #2: zero output on startup
Ran
process was running but looked frozen.
python voicetype_win.py and the console showed nothing. Theprocess was running but looked frozen.
My first guess: pystray is blocking the main thread. Added print
statements everywhere. Still nothing.
statements everywhere. Still nothing.
The actual cause: the Windows console was choking on bytes written
to stderr. pyaudio and pystray write something to stderr at startup
that the console's code page can't render, and the output buffer
stalls.
to stderr. pyaudio and pystray write something to stderr at startup
that the console's code page can't render, and the output buffer
stalls.
Workaround:
1
python -u voicetype_win.py 2>err.txt-u unbuffers stdout, 2>err.txt redirects stderr to a file. Afterthat I could finally see my
STEP1 STEP2 ... diagnostic prints.I spent over an hour debugging "why aren't my
appearing."
flush=True printsappearing."
Pain #3: pynput can't catch F19
On macOS I use Karabiner to remap Caps Lock → F19, and pynput
listens for F19. I tried to replicate this with PowerToys on Windows.
listens for F19. I tried to replicate this with PowerToys on Windows.
The PowerToys remap itself worked (the Caps Lock LED no longer
toggled, meaning the original Caps Lock behavior was suppressed).
But pynput didn't catch F19. The log said
but pressing the key did nothing.
toggled, meaning the original Caps Lock behavior was suppressed).
But pynput didn't catch F19. The log said
hotkey registered: <f19>,but pressing the key did nothing.
I later found that pynput on Windows has known issues with F13 and
above. Switched the hotkey to
immediately.
above. Switched the hotkey to
<ctrl>+<alt>+r and it workedimmediately.
Pain #4: PowerToys ate Ctrl+V
Mid-debug, PowerToys Keyboard Manager somehow hijacked Ctrl+V
itself. Even after stopping Apex Voice, Ctrl+V no longer pasted.
PowerToys' keyboard hook had stacked some leftover state.
itself. Even after stopping Apex Voice, Ctrl+V no longer pasted.
PowerToys' keyboard hook had stacked some leftover state.
Eventually I lost all keyboard input to Notepad and had to
reboot the VM. After that I dropped PowerToys (it felt brittle) and
switched to AutoHotkey v2 for the remap.
reboot the VM. After that I dropped PowerToys (it felt brittle) and
switched to AutoHotkey v2 for the remap.
1
2
3
#Requires AutoHotkey v2.0
SetCapsLockState("AlwaysOff")
CapsLock::Send("^!r")SetCapsLockState("AlwaysOff") suppresses the Caps Lock togglebehavior (the uppercase mode).
Pain #5: Tray clicks steal focus
Click tray → stop recording → Whisper transcribes → paste into
Notepad. The "inserted" log appeared, but nothing showed up in
Notepad. The moment I clicked the tray, focus had moved to the
Command Prompt, and Ctrl+V was pasting there.
Notepad. The "inserted" log appeared, but nothing showed up in
Notepad. The moment I clicked the tray, focus had moved to the
Command Prompt, and Ctrl+V was pasting there.
Fix: use pywin32 to save the foreground window handle when recording
starts, and restore it right before the paste:
starts, and restore it right before the paste:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def _toggle(self, *_):
if not self.recording:
try:
import win32gui
self.inserter._prev_hwnd = win32gui.GetForegroundWindow()
except Exception:
pass
...
def insert(self, text: str):
import win32gui
prev_hwnd = getattr(self, '_prev_hwnd', None)
if prev_hwnd:
try:
win32gui.SetForegroundWindow(prev_hwnd)
time.sleep(0.15)
except Exception:
pass
# then pyperclip + Ctrl+VOn macOS, osascript handled active-app restoration implicitly, so
this code didn't exist. OS-layer differences exposed.
this code didn't exist. OS-layer differences exposed.
Pain #6: Bedrock — MissingDependencyException
aws login works, aws sts get-caller-identity returns my user, butcalling Bedrock via boto3 from Python:
1
2
3
botocore.exceptions.MissingDependencyException: Missing Dependency:
Using the login credentials provider requires an additional dependency.
You will need to pip install "botocore[crt]"Credentials produced by
optional
aws login (AWS CLI 2.32.0+) need botocore'soptional
crt extra:1
pip install "botocore[crt]"After that, polish/formal/translate/bullets all worked.
Pain #7: Caps Lock doesn't reach the VM in UTM
Running the AutoHotkey script as admin, pressing Caps Lock did
nothing. AHK was never triggered.
nothing. AHK was never triggered.
Cut to the chase: the Mac → UTM → Windows key path drops Caps
Lock somewhere. Either UTM's keyboard pass-through doesn't relay
it, or macOS absorbs it at the OS level before UTM sees it. Hard to
pin down.
Lock somewhere. Either UTM's keyboard pass-through doesn't relay
it, or macOS absorbs it at the OS level before UTM sees it. Hard to
pin down.
This wouldn't happen on bare-metal Windows. I gave up on Caps Lock
1-key and shipped with
hotkey. Control+Option+R on the Mac keyboard arrives in Windows as
Ctrl+Alt+R through UTM, so functionally it works.
1-key and shipped with
<ctrl>+<alt>+r pressed directly as thehotkey. Control+Option+R on the Mac keyboard arrives in Windows as
Ctrl+Alt+R through UTM, so functionally it works.
Auto-launch on login
Task Scheduler with
/sc onlogon /rl highest:1
2
schtasks /create /tn "ApexVoice" /tr "%USERPROFILE%\apex_voice_start.bat" /sc onlogon /rl highest /f
schtasks /create /tn "ApexCapsAHK" /tr "%USERPROFILE%\apex_caps.ahk" /sc onlogon /rl highest /fImportant: if Apex Voice runs as administrator, AutoHotkey must also
run as administrator (Windows blocks key injection from a lower-
privilege process to a higher-privilege one).
run as administrator (Windows blocks key injection from a lower-
privilege process to a higher-privilege one).
End state and performance
Measured on UTM (no bare metal at hand):
| Metric | UTM (ARM64) | Estimated (bare-metal Windows) |
|---|---|---|
| 4s audio recognition | ~37s (CPU/int8, large-v3-turbo) | a few seconds |
| Insertion latency | ~250ms | similar |
| Bedrock post-processing | works | works |
| Caps Lock 1-key | impossible (UTM limitation) | should work |
UTM's virtual CPU is too slow for Whisper at this model size. On
real Windows hardware
bare-metal testers for that.
real Windows hardware
large-v3-turbo should be practical. Needbare-metal testers for that.
What I learned
- "Just swap the libraries" is a lie. OS permission models, key
codes, console code pages, focus management — even Python apps
hit all of these at the lower layers. - When console output disappears, suspect stderr. Splitting it
off with2>err.txtmade everything visible. - Key remappers that grab modifiers are dangerous. PowerToys
Keyboard Manager left leftover state that killed Ctrl+V. AHK v2
has been more stable. - The real cost of cross-platform isn't code volume, it's the
number of OS-specific traps you have to step into.
What's next
- Verification on bare-metal Windows. UTM is enough for validation
but I want to confirm performance and the Caps Lock path on real
hardware. - GPU inference. faster-whisper supports CUDA, so an NVIDIA-GPU
Windows machine should be dramatically faster. - AgentCore Memory / Browser / Payments on Windows. The code path
is shared with macOS but I haven't verified it on Windows yet.
The Windows port is functional on UTM, published, and documented in
README_win.md. If you try it on bare metal, issues/PRs welcome:
github.com/yama3133/apex-voice .
README_win.md. If you try it on bare metal, issues/PRs welcome:
github.com/yama3133/apex-voice .
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article