Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Scope

This repository is a Linux/Wayland speech-to-text application. Keep changes focused: runtime behavior is hardware-, compositor-, and systemd-sensitive, while most tests are intentionally isolated with mocks.
This repository is a Linux desktop speech-to-text application supporting Wayland and X11. Keep changes focused: runtime behavior is hardware-, compositor-, and systemd-sensitive, while most tests are intentionally isolated with mocks.

## Repository map

Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ https://github.com/user-attachments/assets/4c223e85-2916-494f-b7b1-766ce1bdc991

---

**Why hyprwhspr?** There are a lotta dictation apps. This one is built ground-up for the highest-end machines: a recent Nvidia card gets the **best possible accuracy and speed**, and everything else runs as well as the hardware you bring. Actively maintained, fully featured, works on anything with Wayland.
**Why hyprwhspr?** There are a lotta dictation apps. This one is built ground-up for the highest-end machines: a recent Nvidia card gets the **best possible accuracy and speed**, and everything else runs as well as the hardware you bring. Actively maintained, fully featured, and built for both Wayland and X11.

---

Expand All @@ -47,7 +47,8 @@ https://github.com/user-attachments/assets/4c223e85-2916-494f-b7b1-766ce1bdc991
### Prerequisites

- **Linux** with systemd (Arch, Debian, Ubuntu, Fedora, openSUSE, etc.)
- **Requires a Wayland session** (GNOME, KDE Plasma Wayland, Sway, Hyprland, Niri)
- **Wayland or X11 session** (GNOME, KDE Plasma, Sway, Hyprland, Niri, etc.). GNOME/X11 on Ubuntu 24.04 is the currently validated X11 configuration.
- **Clipboard/window tools:** `wl-clipboard` and `wtype` on Wayland; `python-pyperclip`, `xclip`, `xdotool`, and `xprop` on X11 (installed by the dependency script)
- **Waybar or Noctalia** (optional, for status bar)
- **gtk4 + PyCairo** (optional, for visualizer)
- **NVIDIA GPU** (optional, for CUDA acceleration)
Expand Down
2 changes: 1 addition & 1 deletion config/systemd/hyprwhspr.service
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ After=wireplumber.service

[Service]
Type=simple
ExecStartPre=/bin/bash -lc 'for i in $(seq 1 60); do ls "$XDG_RUNTIME_DIR"/wayland-* >/dev/null 2>&1 && exit 0; sleep 0.25; done; echo "Wayland socket not found"; exit 1'
ExecStartPre=/bin/bash -lc 'for i in $(seq 1 60); do if [ -n "$WAYLAND_DISPLAY" ]; then case "$WAYLAND_DISPLAY" in /*) wayland_socket="$WAYLAND_DISPLAY" ;; *) wayland_socket="${XDG_RUNTIME_DIR}/${WAYLAND_DISPLAY}" ;; esac; [ -S "$wayland_socket" ] && exit 0; fi; [ -n "$DISPLAY" ] && exit 0; if [ -n "$XDG_RUNTIME_DIR" ]; then for wayland_socket in "${XDG_RUNTIME_DIR}"/wayland-*; do [ -S "$wayland_socket" ] && exit 0; done; fi; sleep 0.25; done; echo "No usable Wayland socket or X11 DISPLAY found"; exit 1'
ExecStart=/usr/lib/hyprwhspr/bin/hyprwhspr
ExecStopPost=/bin/bash -c '( pkill -9 -f "hyprwhspr-virtual-keyboar[d]" 2>/dev/null; pkill -9 -f "hyprwhspr-ydotool.soc[k]" 2>/dev/null ) || true'
Environment=HYPRWHSPR_ROOT=/usr/lib/hyprwhspr
Expand Down
23 changes: 17 additions & 6 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -693,7 +693,7 @@ The recording-status indicator — the **mic OSD** — gives visual feedback whi
`mic_osd_enabled` turns the mic OSD on; *how* it's shown is chosen automatically at startup:

- **Overlay mode** — compositors with layer-shell support (Hyprland, Sway, niri, KDE Plasma Wayland) get the animated always-on-top overlay. Requires GTK4, PyCairo, and `gtk4-layer-shell`.
- **Notification mode** — GNOME/Mutter lacks layer-shell, so status shows as desktop notifications (recording / transcribing / inserted), which never steal the focus the paste needs. Only requires `notify-send` (libnotify).
- **Notification mode** — GNOME/Mutter and X11 sessions use desktop notifications (recording / transcribing / inserted), which never steal the focus the paste needs. The layer-shell overlay is Wayland-only. Notifications require `notify-send` (libnotify).

Set `mic_osd_enabled: false` to turn off both. The service log records which mode was selected:

Expand Down Expand Up @@ -879,7 +879,7 @@ Automatically converts spoken words to symbols and punctuation:

## Paste and clipboard behavior

hyprwhspr copies dictated text to the clipboard, sends a paste shortcut, then restores your clipboard. The hotkey goes out via `wtype` (Wayland virtual-keyboard), falling back to `ydotool key`. Most setups need no configuration. GNOME/Mutter has a few extras — see [GNOME/Mutter notes](#gnomemutter-notes).
hyprwhspr copies dictated text to the clipboard, sends a paste shortcut, then restores your clipboard. Wayland prefers `wl-clipboard` plus `wtype` (falling back to `ydotool key`). X11 uses `python-pyperclip` with `xclip`, and `xdotool`/`xprop` for focused-window and terminal detection. `xsel` is also accepted as a clipboard fallback when installed. Most setups need no configuration. GNOME/Mutter has a few extras — see [GNOME/Mutter notes](#gnomemutter-notes).

### Paste mode

Expand Down Expand Up @@ -954,10 +954,11 @@ hyprwhspr saves your clipboard before injection and restores it afterward — di

### GNOME/Mutter notes

GNOME/Mutter lacks layer-shell and blocks `wtype`, so hyprwhspr behaves differently there:
GNOME/Mutter lacks layer-shell, so visual feedback uses notifications. Injection depends on the session:

- **Window detection** uses the AT-SPI accessibility bridge — `hyprwhspr setup` offers to enable it (`gsettings set org.gnome.desktop.interface toolkit-accessibility true`). Without it, GNOME can't tell terminals apart and paste falls back to Ctrl+V. An explicit `paste_mode` (with no `applications` rules) skips the probe entirely.
- **Direct typing:** ASCII text on a US layout is typed directly with `ydotool type`; anything else falls back to clipboard paste automatically. Set `"prefer_clipboard_paste": true` to always use clipboard paste.
- **GNOME Wayland direct typing:** Mutter blocks `wtype`, so ASCII text on a US layout is typed directly with `ydotool type`; anything else falls back to clipboard paste automatically. Set `"prefer_clipboard_paste": true` to always use clipboard paste.
- **GNOME X11 clipboard paste:** X11 uses `xclip` and a normal paste chord rather than the Wayland-only direct-typing workaround. GNOME/X11 on Ubuntu 24.04 is the currently validated X11 configuration.
- **Non-Latin layouts** (Thai, Russian, Arabic, …): no physical key produces a `v` keysym, so hyprwhspr briefly switches to a Latin input source for the paste chord and restores your layout after — just keep a Latin source in Settings → Keyboard → Input Sources.

### Post-transcription hook
Expand Down Expand Up @@ -1255,8 +1256,8 @@ Check your session:
# Verify graphical-session.target is active
systemctl --user is-active graphical-session.target

# Verify Wayland env is available to systemd services
systemctl --user show-environment | grep -E 'WAYLAND_DISPLAY|NIRI_SOCKET'
# Verify the active display environment is available to systemd services
systemctl --user show-environment | grep -E 'WAYLAND_DISPLAY|DISPLAY|XAUTHORITY|NIRI_SOCKET'
```

If `WAYLAND_DISPLAY` is missing, add to `~/.config/hypr/hyprland.conf`:
Expand All @@ -1272,6 +1273,16 @@ socket for its own process and children. The compositor environment export above
is still the recommended fix because it makes the correct display available to
all systemd user services.

For X11, `DISPLAY` must be present and `XAUTHORITY` should be imported when your
session uses it:

```bash
systemctl --user import-environment DISPLAY XAUTHORITY XDG_SESSION_TYPE XDG_CURRENT_DESKTOP
systemctl --user show-environment | grep -E 'DISPLAY|XAUTHORITY|XDG_SESSION_TYPE'
```

Do not set `WAYLAND_DISPLAY` in an explicit `XDG_SESSION_TYPE=x11` session.

**Niri:**

hyprwhspr uses `niri msg --json focused-window` to detect the focused app and choose the correct paste shortcut. That requires `NIRI_SOCKET` to be available in the systemd user environment used by `hyprwhspr.service`.
Expand Down
29 changes: 22 additions & 7 deletions lib/src/cli/maintenance.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
management and installation validation
"""

import os
import shutil
import subprocess
from pathlib import Path
Expand Down Expand Up @@ -40,6 +41,11 @@
except ImportError:
from output_control import log_info, log_success, log_warning, log_error

try:
from ..session_environment import classify_display_environment
except ImportError:
from session_environment import classify_display_environment

from ._shared import (HYPRWHSPR_ROOT, SERVICE_NAME, _check_ydotool_version,
_is_niri_session, _validate_hyprwhspr_root)

Expand Down Expand Up @@ -772,20 +778,29 @@ def validate_command():
except Exception:
pass

# Check Wayland compositor environment in systemd user environment
# Check graphical session environment in the systemd user environment.
try:
result = subprocess.run(
['systemctl', '--user', 'show-environment'],
capture_output=True, text=True, timeout=5, check=False
)
env_output = result.stdout if result.returncode == 0 else ''
if 'WAYLAND_DISPLAY=' in env_output:
log_success("✓ WAYLAND_DISPLAY set in systemd user environment")
display_kind = classify_display_environment(
env_output, os.environ.get('XDG_SESSION_TYPE', '')
)
if display_kind == 'wayland':
log_success("✓ Wayland display set in systemd user environment")
elif display_kind == 'x11':
log_success("✓ X11 DISPLAY set in systemd user environment")
else:
log_warning("⚠ WAYLAND_DISPLAY not found in systemd user environment")
print(" Add the relevant compositor environment export to your startup config.")
print(" Hyprland example:")
print(" exec-once = dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP HYPRLAND_INSTANCE_SIGNATURE")
log_warning("⚠ No Wayland or X11 display found in systemd user environment")
if os.environ.get('XDG_SESSION_TYPE', '').lower() == 'x11':
print(" Import the X11 session environment:")
print(" systemctl --user import-environment DISPLAY XAUTHORITY XDG_SESSION_TYPE")
else:
print(" Add the relevant compositor environment export to your startup config.")
print(" Hyprland example:")
print(" exec-once = dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP HYPRLAND_INSTANCE_SIGNATURE")

if _is_niri_session():
if 'NIRI_SOCKET=' in env_output:
Expand Down
5 changes: 3 additions & 2 deletions lib/src/cli/systemd.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,10 +122,11 @@ def setup_systemd(mode: str = 'install'):
# Import the compositor environment visible to this setup process into the
# systemd user manager. Niri's focused-window IPC needs NIRI_SOCKET; Hyprland
# detection needs HYPRLAND_INSTANCE_SIGNATURE; wtype/wl-clipboard need the
# Wayland display environment.
# graphical-session environment used by Wayland and X11 integrations.
run_command([
'systemctl', '--user', 'import-environment',
'WAYLAND_DISPLAY', 'XDG_CURRENT_DESKTOP',
'WAYLAND_DISPLAY', 'DISPLAY', 'XAUTHORITY', 'XDG_SESSION_TYPE',
'XDG_CURRENT_DESKTOP', 'XDG_SESSION_DESKTOP', 'DESKTOP_SESSION',
'HYPRLAND_INSTANCE_SIGNATURE', 'NIRI_SOCKET',
], check=False)

Expand Down
22 changes: 22 additions & 0 deletions lib/src/session_environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,25 @@
from pathlib import Path


def classify_display_environment(environment: str, expected_session_type: str = ""):
"""Return the usable display kind exported by a systemd environment dump."""
values = {}
for line in environment.splitlines():
name, separator, value = line.partition("=")
if separator:
values[name] = value
session_type = (values.get("XDG_SESSION_TYPE") or expected_session_type).lower()
if session_type == "wayland":
return "wayland" if values.get("WAYLAND_DISPLAY") else None
if session_type == "x11":
return "x11" if values.get("DISPLAY") else None
if values.get("WAYLAND_DISPLAY"):
return "wayland"
if values.get("DISPLAY"):
return "x11"
return None


def ensure_wayland_display():
"""
Populate WAYLAND_DISPLAY from XDG_RUNTIME_DIR when systemd has not imported it.
Expand All @@ -17,6 +36,9 @@ def ensure_wayland_display():
if os.environ.get("WAYLAND_DISPLAY"):
return

if os.environ.get("XDG_SESSION_TYPE", "").lower() == "x11":
return

runtime_dir = os.environ.get("XDG_RUNTIME_DIR")
if not runtime_dir:
print("[WARN] WAYLAND_DISPLAY unset and XDG_RUNTIME_DIR missing", flush=True)
Expand Down
Loading
Loading