Skip to content

Windows 11 support - #7

Draft
itsdevcoffee wants to merge 45 commits into
mainfrom
feat/windows-port
Draft

itsdevcoffee wants to merge 45 commits into
mainfrom
feat/windows-port

Conversation

@itsdevcoffee

Copy link
Copy Markdown
Owner

Work in progress: Windows 11 port. Plan and decisions: docs/research/2026-10-03-windows-port-scoping.md.

Done so far

  • Cross-platform daemon IPC (interprocess): Unix socket unchanged, per-user named pipe on Windows; desktop app now uses the core client
  • Portable process liveness and stop requests (toggle.rs): SIGUSR1 on Unix, stop file on Windows
  • icon.ico for Tauri Windows builds
  • CI: windows-latest job running check, unit tests, release build, and the end-to-end transcription smoke test

Verified

  • Cross-compiles for x86_64-pc-windows-gnu; clippy clean (--all-targets -D warnings) on Linux and Windows targets
  • Linux: smoke test, duplicate-daemon guard, socket cleanup on shutdown, recovery from a stale socket after kill -9

Remaining (P1–P3 in the scoping doc): shell-outs to Linux tools, desktop sidecar/console windows, CUDA on Windows, global hotkey in the daemon, NSIS release job.

🤖 Generated with Claude Code

itsdevcoffee and others added 30 commits October 3, 2026 19:37
Replace std::os::unix::net with interprocess local sockets: the same socket
file on Unix, a per-user named pipe on Windows. is_daemon_running is now a
ping with a 2s timeout instead of checking for the socket file, and the
desktop app's duplicate protocol/client is replaced by the core one.

Co-Authored-By: Claude <[email protected]>
Unix keeps kill(pid, 0) and SIGUSR1. Windows checks liveness with
OpenProcess/GetExitCodeProcess and sends stop requests as a stop-<pid> file
that a watcher thread in the target process turns into STOP_RECORDING.

Co-Authored-By: Claude <[email protected]>
tauri-build needs an .ico to build on Windows. The new windows-latest job
runs check, unit tests, a release build, and the transcription smoke test
(Git Bash). The smoke script now writes the model path as a TOML literal
string so Windows backslashes aren't treated as escapes.

Co-Authored-By: Claude <[email protected]>
- daemon logs: tail/follow implemented in Rust instead of spawning tail
- doctor: PipeWire check only on Linux; suggest the real default model
- notify-send and the Waybar refresh_command default are Linux-only

Co-Authored-By: Claude <[email protected]>
…p app

- Find the CLI next to the app, in ~/.local/bin, or on PATH (no `which`)
- Detect the running daemon's executable from its PID file via sysinfo
  (no `ps aux`), and do it before shutdown during restart; it previously
  ran after the daemon had exited, so detection could never succeed
- Spawn console programs with CREATE_NO_WINDOW on Windows
- GPU detection via nvidia-smi on Windows too; ~ expansion via dirs

Co-Authored-By: Claude <[email protected]>
sed -i writes its temp file in the cwd (D: on the runner), which can't be
renamed onto the config on C:.

Co-Authored-By: Claude <[email protected]>
New [hotkey] toggle setting (default Ctrl+Alt+Space on Windows, unset
elsewhere). The daemon registers it with global-hotkey on a thread running a
Win32 message loop; each press runs `mojovoice start` hidden, reusing the
CLI toggle (start/stop recording, type the transcription). On other
platforms a configured hotkey only logs a pointer to desktop keybindings.

Co-Authored-By: Claude <[email protected]>
save_config overwrote config.toml with only the app's sections, which
would drop [hotkey] (and any section added by newer CLI versions).

Co-Authored-By: Claude <[email protected]>
GNU sed treats backslashes in the replacement as escapes (\U, \r, ...),
which mangled C:\Users\... in the config.

Co-Authored-By: Claude <[email protected]>
tauri.windows.conf.json limits Windows bundles to NSIS and bundles the CLI as
a sidecar next to the app, where find_mojovoice_binary looks first. The CI
job uploads the installer and a CLI zip as artifacts for manual testing.

Co-Authored-By: Claude <[email protected]>
ipc.ts detected Tauri by checking for window.__TAURI__, which Tauri v2 only
defines when app.withGlobalTauri is enabled (it is false). The desktop app
on every platform therefore served the browser-mode mock data: fake models,
'mock path' settings, and no-op model switching, deleting and downloading.
Use isTauri() from @tauri-apps/api/core instead.

Co-Authored-By: Claude <[email protected]>
The app's own partial copy of the config struct required fields the CLI gives
defaults for, so older config.toml files failed to load (Settings stuck on
loading), and saving dropped output.display_server. Reuse
mojovoice::config::Config instead. switch_model now stores models_dir/<name>
rather than the canonicalized path, which has a \\?\ prefix on Windows.

Co-Authored-By: Claude <[email protected]>
Built against mock data, several views broke on real responses:
- Models: storage bar threw on undefined models_size_gb (blank panel);
  sizes/download/active state read fields the registry doesn't send (now
  joined with list_downloaded_models); delete sent { modelName } instead of
  { filename }, so it always failed. Reloads after download/delete.
- System status: read snake_case fields the backend sends in camelCase.
- Status bar: read modelId/deviceName (config keys are snake_case) and never
  highlighted the active model.
- Settings: model switch split paths on '/' only, failing on Windows.
- Downloads that fail before the first progress event now show an error
  instead of staying stuck on 'downloading'.
Mocks now use the same shapes as the backend.

Co-Authored-By: Claude <[email protected]>
Panels collapsed by animating max-height to a fixed cap (Models 800px,
System status/Advanced 400px, history 2000px), so the 22-model list was cut
off. Use a grid-template-rows 0fr/1fr collapse instead (animates, no cap).
Advanced settings: native folder picker (Browse) for model_path and
clips_path. Navigating to Models/History from settings closes the drawer.

Co-Authored-By: Claude <[email protected]>
- Switching models restarts the daemon (10-15s+), but the pickers showed no
  progress and the backend gave up after 5s. One shared switchModel in the
  store: pickers show the pending model with a spinner, are disabled until
  done (no stacked restarts), and report success/failure.
- The daemon start/restart wait is now up to 180s, polls off the async
  runtime, and fails fast with a pointer to the logs if the daemon exits.
- First run: with no models installed the UI showed the configured (not
  downloaded) model as selected. Show 'No model installed' with a button to
  the Models page; the status bar names the installed active model.
- Model pickers use the directory name as their value instead of the path.

Co-Authored-By: Claude <[email protected]>
# Conflicts:
#	ui/src/components/SettingsPanel.tsx
#	ui/src/components/ui/StatusBar.tsx
There was no way to tell whether MojoVoice was recording or transcribing.
The daemon now tracks its activity (daemon::indicator) around the recording
and transcription requests it handles, and on Windows shows a tray icon on
the same message-loop thread as the global hotkey: grey when ready, red while
recording, amber while transcribing, with a tooltip naming the hotkey and a
'Quit MojoVoice daemon' menu item. Hotkey registration failures no longer
prevent the tray from appearing.

Co-Authored-By: Claude <[email protected]>
Candle links CUDA at load time (cudarc dynamic-linking), so a CUDA build of
mojovoice.exe can't even start without the CUDA 12 runtime. The installer now
bundles both mojovoice.exe (CPU) and mojovoice-cuda.exe (compute capability
8.0+), built on windows-2022 since CUDA 12 rejects newer Visual Studio. The
desktop app probes for the DLLs the CUDA build imports (nvcuda, cuBLAS,
cuBLASLt, cuRAND) and starts the CUDA daemon when they load, otherwise the
CPU one. Releases also publish mojovoice-windows-x64-cuda.zip.

Co-Authored-By: Claude <[email protected]>
Candle only accelerates on NVIDIA (CUDA) and Apple (Metal), so AMD and Intel
GPUs fell back to the CPU. Add a whisper.cpp engine (whisper-rs) behind the
whisper-cpp feature; whisper-vulkan adds its Vulkan backend, which runs on any
GPU driver with Vulkan. The model format picks the engine (transcribe::
load_engine): GGML (model.bin) -> whisper.cpp, safetensors/GGUF -> Candle.
Engines report their device for status, and whisper.cpp logs go to tracing.

On an RTX 3090 Ti via Vulkan, GGML large-v3-turbo: post-warmup RTF 0.026
and WER 0.173 (Candle+CUDA: 0.037 / 0.172).

Co-Authored-By: Claude <[email protected]>
…ile list

Adds whisper.cpp's GGML models (large-v3-turbo full/Q8/Q5, distil-large-v3.5,
medium.en, small.en, base.en) as a single model.bin each. The desktop app now
takes download files from ModelInfo::files() instead of its own per-format
logic, recognizes GGML downloads, and picks the bundled CLI build: CUDA
(NVIDIA + CUDA 12 runtime), else Vulkan (vulkan-1.dll), else CPU.

Co-Authored-By: Claude <[email protected]>
itsdevcoffee and others added 15 commits October 4, 2026 04:52
Installer sidecars: mojovoice.exe (Candle + whisper.cpp CPU),
mojovoice-vulkan.exe (whisper.cpp on Vulkan) and mojovoice-cuda.exe (Candle
CUDA + whisper.cpp on Vulkan). Installs the Vulkan SDK for glslc/headers.
UI labels GGML models as whisper.cpp / GPU via Vulkan and suggests them for
AMD/Intel GPUs on first run.

Co-Authored-By: Claude <[email protected]>
whisper.cpp's nested vulkan-shaders-gen MSBuild project exceeded the
260-character path limit under target-vulkan/ (FileTracker FTK1011).

Co-Authored-By: Claude <[email protected]>
whisper.cpp takes the first GPU it finds; on a machine with integrated
graphics plus a discrete card (Intel UHD 770 + AMD RX 6600) that's the
integrated one, which made transcription near CPU speed. Enumerate ggml
devices and pass the first discrete GPU (else the first integrated one) as
gpu_device. Status now names the GPU, e.g. 'whisper.cpp (Vulkan: AMD Radeon
RX 6600)'.

Co-Authored-By: Claude <[email protected]>
Windows 11 throttles background processes without a window. The daemon is
exactly that, which can slow transcription severalfold; on Wario a test
daemon ran whisper.cpp ~9x slower than the same build on a comparable Linux
CPU.

Co-Authored-By: Claude <[email protected]>
- Flash attention by default when running on a GPU: on an RTX 3090 Ti via
  Vulkan, benchmark total 8.02s -> 5.74s and short clips RTF 0.144 -> 0.090,
  identical WER. MOJOVOICE_WHISPER_FLASH_ATTN overrides it.
- MOJOVOICE_WHISPER_AUDIO_CTX=auto trims the encoder window to the clip
  length; off by default since it doubled WER (0.17 -> 0.34).
- Windows builds set GGML_NATIVE=OFF with AVX/AVX2/FMA/F16C/BMI2: native
  detection baked in the CI runner's AVX-512, which desktop CPUs like the
  i7-12700K don't have.

Co-Authored-By: Claude <[email protected]>
After stop, recording continues briefly so words spoken while pressing the key
aren't cut off. 1s added a full second to every dictation; 400ms covers the
same case (configurable: audio.trailing_buffer_ms). Cancelled recordings are
discarded, so they now skip it entirely.

Also fixes a Windows thread leak: setup_signal_handler, called for every
daemon recording, started a new never-ending stop-file watcher each time.

Co-Authored-By: Claude <[email protected]>
Test Mic recorded for a fixed 5s with a CANCEL button beside it and no stop
button, so 'stop' clicks landed on Cancel; the 5s timer then sent
stop_recording for an already-cancelled recording ('Daemon error: Not
recording'). Now: click to start, click again to stop & transcribe (auto-stop
at 30s), Cancel discards and clears the pending auto-stop. Shows recording
time and how long transcription took.

Co-Authored-By: Claude <[email protected]>
The CUDA toolkit install and build add ~10-15 minutes per CI run. PR test
builds now bundle only the CPU and Vulkan CLIs (tauri.windows-test.conf.json
overrides externalBin); release builds still include CUDA.

Co-Authored-By: Claude <[email protected]>
- Settings > RECORDING > hotkey: shows the current combination; Change
  captures the next key combination (KeyboardEvent.code names, which is the
  format global-hotkey parses), requires a modifier unless it's F1-F24/Pause
  etc., saves [hotkey] toggle and restarts a running daemon to apply it.
- The daemon logs every hotkey press/release and the 'mojovoice start' it
  launches (and its exit status on failure). Wario's log showed no trace of
  Ctrl+Alt+Space presses at all, so this shows whether presses arrive.

Co-Authored-By: Claude <[email protected]>
…hift+1

- Status overlay: a small always-on-top pill near the bottom of the screen
  (layered, click-through, never activated, DPI-aware; drawn with GDI on the
  daemon's UI thread). Shows 'Recording' (with the stop hint), 'Transcribing',
  then the transcribed text for 1.6s, 'No speech detected', or the actual
  error for 5s. [overlay] enabled, on by default.
- Push-to-talk ([hotkey] mode = push_to_talk, off by default): press starts
  recording, release transcribes and types; taps under 300ms are cancelled.
  The release waits for the press's CLI run so the stop can't overtake it.
- Default hotkey is now Alt+Shift+1: Ctrl+Alt+Space never reached the daemon
  on a test machine (likely taken by another app's keyboard hook).
- The daemon records how each dictation ended (indicator::finish).

Co-Authored-By: Claude <[email protected]>
Both restart a running daemon to apply, sharing one save-and-restart helper
with the hotkey picker.

Co-Authored-By: Claude <[email protected]>
…for it

'ready for transcription' is logged after the model loads, but the daemon
only accepts connections once the tray/overlay are up (~0.5s later now). On
Windows a named pipe refuses connections until then, so the smoke test's
request raced it and failed. Log 'accepting connections' right before the
accept loop and wait for that instead.

Co-Authored-By: Claude <[email protected]>
Windows sends the hidden overlay a WM_PAINT before any status text is set,
and DrawTextW was called with an empty Vec's dangling pointer (length 0),
which user32 still reads: access violation (0xC0000005 / 0xC000041D) right
after startup. Skip drawing when there's no text. Found by reproducing on
Wario via the Application event log; verified the fixed build survives
startup and painting.

Co-Authored-By: Claude <[email protected]>
DaemonServer::new ignored its model_path argument and always loaded the
config's model, while logging the override.

Co-Authored-By: Claude <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant