chore(release): prepare v0.14.1-beta (#525)

Patch release for Flatpak/AUR packaging and post-0.14.0 fixes:
installer wrappers, ydotool dual API and Flatpak socket probe, model
download timeouts, layout-aware hotkeys, and related docs/web bumps.
This commit is contained in:
Jatin K Malik
2026-07-16 17:51:45 -07:00
committed by GitHub
parent 3deb523efd
commit 853c1504b2
17 changed files with 620 additions and 161 deletions
+12 -26
View File
@@ -32,42 +32,28 @@ It's a free, GPLv3-licensed desktop app that lets you dictate text into *any* ap
No internet required. No data leaves your machine. Just speak and type.
## 📚 What's New in v0.14.0-beta
## 📚 What's New in v0.14.1-beta
> 🎉 **Release: Configurable keyboard shortcuts, FunASR/SenseVoice remote-API support, and another round of Wayland reliability fixes.**
> **Release: Flatpak packaging, AUR package, layout-aware hotkeys, and installer/text-injection fixes.**
### 🚀 Highlights
### Highlights
| Feature | Description |
|---------|-------------|
| **⌨️ Configurable Shortcuts** | Bind any modifier combination to a key — e.g. `Alt+R`, `Ctrl+Shift+V`, or `Super+F10` |
| **🌐 FunASR / SenseVoice Remote API** | Remote-API engine now supports FunASR and SenseVoice models via OpenAI-compatible endpoints |
| **🖥️ GNOME Wayland IBus Reliability** | Text injection works again on GNOME Wayland with bare `xkb` layouts and engine restore fallbacks are fixed |
| **🎙️ Audio Crash Fix** | Recording no longer crashes when the system audio device index changes between sessions |
| **⚡ Hybrid-CPU Efficiency** | whisper.cpp no longer defaults to all cores on hybrid Intel/AMD processors |
| **Flatpak packaging** | Distro-independent install with whisper.cpp, global hotkeys (evdev), and Wayland text injection (wl-copy + ydotool) |
| **AUR package** | Arch users can install via `yay -S vocalinux` |
| **Layout-aware hotkeys** | Combo keys work correctly on non-US keyboard layouts |
| **Installer / text injection** | `sg` fix for Ubuntu 26.04/Debian 13; treat XIM `none` as unset |
### ✨ New Features
See [docs/UPDATE.md](docs/UPDATE.md) and the [full changelog](https://github.com/jatinkrmalik/vocalinux/releases/tag/v0.14.1-beta).
- **Configurable modifier+key hotkeys** — The Settings dialog now lets you set custom shortcuts using any combination of Ctrl, Alt, Shift, and Super plus a letter/number key. The legacy defaults still work, and you can now bind combinations like `Alt+R` or `Ctrl+Shift+V` (#493)
- **Remote API FunASR/SenseVoice support** — OpenAI-compatible remote endpoints can specify FunASR/SenseVoice model names (e.g. `sensevoice`) and return richer response shapes; SenseVoice metadata labels are stripped before text injection (#468)
### Previous: v0.14.0-beta
### 🐛 Bug Fixes
- **GNOME Wayland/IBus**: Restore text injection when only a bare `xkb` engine is configured; the engine restore fallback now picks the correct IM engine instead of silently dropping text (#506, #500)
- **KDE Wayland/IBus**: Restore the KDE Plasma Wayland IBus text-injection path that was regressed in recent compositor-detection changes (#502)
- **Wayland injection**: Wait for held modifiers (Ctrl/Alt/Shift/Super) to release before injecting text, preventing accidental shortcut triggers and garbled output on modifier-heavy workflows (#494)
- **Shortcuts UI**: Keep preset and custom shortcut selection exclusive — selecting a preset now clears the custom field, and setting a custom combo selects the "Custom Shortcut" preset (#509)
- **whisper.cpp**: Stop defaulting to all CPU cores on hybrid processors (Intel Performance + Efficient cores), which caused UI lag and excess battery drain (#492)
- **Audio**: Fix a crash on recording start when the selected audio device index no longer matches the current system enumeration (#499)
- **Installer**: Include `xsel` as a fallback for the Wayland clipboard path when `xclip` is unavailable (#496)
### 🔧 Improvements
- **Code style** — Removed an outdated long comment about whisper.cpp default thread counts (#505)
Configurable shortcuts, FunASR/SenseVoice remote API, Wayland IBus reliability, audio crash fix, hybrid-CPU efficiency. Details in [UPDATE.md](docs/UPDATE.md).
---
## ✨ Features
## Features
- 🎤 **Toggle or Push-to-Talk** activation modes
- ⚡ **Real-time transcription** with minimal latency
@@ -440,7 +426,7 @@ Vocalinux is part of a family of privacy-first, offline voice dictation tools. S
| Platform | Project | Website | GitHub | Status |
|----------|---------|---------|--------|--------|
| 🐧 Linux | **VocaLinux** | [vocalinux.com](https://vocalinux.com) | [jatinkrmalik/vocalinux](https://github.com/jatinkrmalik/vocalinux) | ✅ Beta v0.14.0 |
| 🐧 Linux | **VocaLinux** | [vocalinux.com](https://vocalinux.com) | [jatinkrmalik/vocalinux](https://github.com/jatinkrmalik/vocalinux) | ✅ Beta v0.14.1 |
| 🍎 macOS | **VocaMac** | [vocamac.com](https://vocamac.com) | [jatinkrmalik/vocamac](https://github.com/jatinkrmalik/vocamac) | 🚀 Beta |
| 🪟 Windows | **VocaWin** | [vocawin.com](https://vocawin.com) | [jatinkrmalik/vocawin](https://github.com/jatinkrmalik/vocawin) | 📋 Planned |
+1
View File
@@ -345,6 +345,7 @@ git push origin v0.5.1-beta
| 0.12.0-beta | 2026-06-07 | Beta | Remote API engine, Silero VAD, threading/IBus hardening, settings and installer fixes |
| 0.13.0-beta | 2026-06-30 | Beta | Guided whisper.cpp model variants, Wayland text-injection reliability, hotplug keyboard support, dictation spacing fix, website docs refresh |
| 0.14.0-beta | 2026-07-13 | Beta | Configurable modifier+key hotkeys, FunASR/SenseVoice remote-API support, GNOME/KDE Wayland IBus reliability fixes, audio crash fix, hybrid-CPU efficiency fix |
| 0.14.1-beta | 2026-07-16 | Beta | Flatpak packaging, AUR package, layout-aware hotkeys, installer/text-injection fixes |
## Questions?
+40 -8
View File
@@ -2,17 +2,49 @@
This guide explains how to update Vocalinux to the latest version.
## What's New in v0.14.0-beta
## What's New in v0.14.1-beta
### 🚀 Highlights
### Highlights
| Feature | Description |
|---------|-------------|
| **⌨️ Configurable Shortcuts** | Bind any modifier combination to a key — e.g. `Alt+R`, `Ctrl+Shift+V`, or `Super+F10` |
| **🌐 FunASR / SenseVoice Remote API** | Remote-API engine now supports FunASR and SenseVoice models via OpenAI-compatible endpoints |
| **🖥️ GNOME Wayland IBus Reliability** | Text injection works again on GNOME Wayland with bare `xkb` layouts and engine restore fallbacks are fixed |
| **🎙️ Audio Crash Fix** | Recording no longer crashes when the system audio device index changes between sessions |
| **⚡ Hybrid-CPU Efficiency** | whisper.cpp no longer defaults to all cores on hybrid Intel/AMD processors |
| **Flatpak packaging** | Universal Flatpak (whisper.cpp) with sandbox-aware paths, global hotkeys, and Wayland paste injection |
| **AUR package** | Official Arch packaging and CI publish path |
| **Layout-aware hotkeys** | Combo keys respect non-US layouts |
| **Installer / injection fixes** | `sg` on Ubuntu 26.04/Debian 13; XIM `none` treated as unset |
### New Features
- **Flatpak packaging** — GNOME Platform 50, whisper.cpp + Vulkan, ydotool/wl-copy injection, evdev global shortcuts; build via `packaging/flatpak/` (#484, closes #167)
- **AUR release package** — PKGBUILD and CI publish for Arch Linux (#518)
### Bug Fixes
- **Hotkeys**: Layout-aware combo keys for non-US layouts (#514)
- **Installer**: `sg` not found on Ubuntu 26.04 / Debian 13 (#524)
- **Text injection**: Treat XIM `none` as unset (#512)
- **Web**: Dependabot npm alerts in package-lock (#515)
### Docs
- Refreshed v0.14 UI screenshots and website gallery (#521)
- README Star History and related polish
See the [full changelog](https://github.com/jatinkrmalik/vocalinux/releases/tag/v0.14.1-beta).
---
## What's New in v0.14.0-beta
### Highlights
| Feature | Description |
|---------|-------------|
| **Configurable Shortcuts** | Bind any modifier combination to a key — e.g. `Alt+R`, `Ctrl+Shift+V`, or `Super+F10` |
| **FunASR / SenseVoice Remote API** | Remote-API engine now supports FunASR and SenseVoice models via OpenAI-compatible endpoints |
| **GNOME Wayland IBus Reliability** | Text injection works again on GNOME Wayland with bare `xkb` layouts and engine restore fallbacks are fixed |
| **Audio Crash Fix** | Recording no longer crashes when the system audio device index changes between sessions |
| **Hybrid-CPU Efficiency** | whisper.cpp no longer defaults to all cores on hybrid Intel/AMD processors |
### ✨ New Features
@@ -273,7 +305,7 @@ The installer will:
```bash
cd vocalinux
git fetch origin
git checkout v0.14.0-beta
git checkout v0.14.1-beta
./install.sh
```
+13 -7
View File
@@ -420,8 +420,12 @@ if [ "$IS_VOCALINUX_LOCAL" = true ]; then
# Running from within the vocalinux repo
INSTALL_DIR="$(pwd)"
print_info "Running from local repository: $INSTALL_DIR"
# Convert VENV_DIR to absolute path for wrapper scripts
VENV_DIR="$INSTALL_DIR/$VENV_DIR"
# Convert relative VENV_DIR to absolute for wrapper scripts.
# Absolute --venv-dir= paths must not be re-prefixed with INSTALL_DIR.
case "$VENV_DIR" in
/*) ;;
*) VENV_DIR="$INSTALL_DIR/$VENV_DIR" ;;
esac
else
# Running remotely (e.g., via curl | bash)
print_info "Installing Vocalinux version: ${INSTALL_TAG}"
@@ -3075,12 +3079,14 @@ REMOTE_CONFIG
# Create wrapper scripts in ~/.local/bin for easy access
mkdir -p "$HOME/.local/bin"
# Shared sg check logic for wrapper scripts
# Uses sg to activate input group for Wayland keyboard shortcuts without logout
local SG_CHECK='if grep -q "^input:.*\b\$(whoami)\b" /etc/group 2>/dev/null && ! groups | grep -q "\binput\b" && command -v sg &>/dev/null; then
exec sg input -c "\$EXEC_CMD"
# Shared sg check logic for wrapper scripts.
# Uses sg to activate the input group for Wayland keyboard shortcuts without logout.
# Single-quoted so install.sh does not expand; the wrapper expands $(whoami)/$EXEC_CMD
# at runtime. Do not write \$ — that leaves a literal $EXEC_CMD for exec.
local SG_CHECK='if grep -q "^input:.*\b$(whoami)\b" /etc/group 2>/dev/null && ! groups | grep -q "\binput\b" && command -v sg &>/dev/null; then
exec sg input -c "$EXEC_CMD"
else
exec \$EXEC_CMD
exec $EXEC_CMD
fi'
# Create vocalinux wrapper script
+2 -2
View File
@@ -3,8 +3,8 @@
pkgname=vocalinux
# AUR pkgver cannot contain hyphens (v0.14.0-beta -> 0.14.0beta).
pkgver=0.14.0beta
_tag=0.14.0-beta
pkgver=0.14.1beta
_tag=0.14.1-beta
pkgrel=1
pkgdesc="Free, offline voice dictation for Linux"
arch=('any')
+1 -1
View File
@@ -78,7 +78,7 @@ pipx run flatpak-pip-generator \
sources:
- type: git
url: https://github.com/jatinkrmalik/vocalinux.git
tag: v0.14.0-beta
tag: v0.14.1-beta
commit: <release-commit-sha>
```
@@ -105,6 +105,11 @@
</content_rating>
<releases>
<release version="0.14.1-beta" date="2026-07-16">
<description>
<p>First Flathub-oriented packaging release: Flatpak with whisper.cpp + Vulkan, wl-copy/ydotool Wayland paste, evdev global hotkeys, AUR package, layout-aware shortcuts, and installer fixes.</p>
</description>
</release>
<release version="0.14.0-beta" date="2026-07-14">
<description>
<p>Vocalinux 0.14.0-beta as Flatpak: whisper.cpp with Vulkan, XDG config/models/logs, XWayland text injection, and system tray integration.</p>
@@ -93,6 +93,8 @@ modules:
sha256: 6eb8081207fb5581d1d82c4bcd9587205a31a3d47bea3ebeb7f41aa1143783eb
# uinput-based typing / paste chords for native Wayland clients.
# Pinned to v1.0.4 (keycode:value key API). Distro 0.1.x uses "ctrl+v" instead;
# vocalinux detects the installed CLI at runtime (see TextInjector).
- name: ydotool
buildsystem: cmake-ninja
config-opts:
@@ -105,6 +107,11 @@ modules:
- type: archive
url: https://github.com/ReimuNotMoe/ydotool/archive/refs/tags/v1.0.4.tar.gz
sha256: ba075a43aa6ead51940e892ecffa4d0b8b40c241e4e2bc4bd9bd26b61fde23bd
x-checker-data:
type: anitya
project-id: 138160
stable-only: true
url-template: https://github.com/ReimuNotMoe/ydotool/archive/refs/tags/v$version.tar.gz
- type: shell
commands:
# scdoc is not in the SDK; man pages are not needed in the sandbox.
@@ -1684,6 +1684,92 @@ class SpeechRecognitionManager:
logger.debug(f"whisper.cpp server API format attempt failed: {e}")
return None
# (connect timeout, read timeout) for model HTTP downloads. HF often hangs
# without a timeout when the CDN is degraded (e.g. 504 / empty body).
_MODEL_DOWNLOAD_TIMEOUT = (15, 120)
def _stream_model_download(self, url: str, dest_path: str) -> None:
"""Stream a model file from ``url`` to ``dest_path`` with progress.
Raises RuntimeError on cancel, HTTP errors, timeouts, or empty body.
"""
import requests
logger.info(f"Downloading from {url}")
response = requests.get(
url,
stream=True,
timeout=self._MODEL_DOWNLOAD_TIMEOUT,
headers={"User-Agent": "vocalinux/0.14.1"},
)
response.raise_for_status()
content_type = (response.headers.get("content-type") or "").lower()
if "text/html" in content_type:
raise RuntimeError(
f"Model URL returned HTML instead of a binary file "
f"(status {response.status_code}, content-type {content_type}). "
f"Hugging Face may be unavailable; try again later."
)
total_size = int(response.headers.get("content-length", 0))
downloaded_size = 0
start_time = time.time()
last_update_time = start_time
chunk_size = 8192
with open(dest_path, "wb") as f:
for data in response.iter_content(chunk_size=chunk_size):
if self._download_cancelled:
logger.info("Download cancelled by user")
f.close()
if os.path.exists(dest_path):
os.remove(dest_path)
raise RuntimeError("Download cancelled")
if not data:
continue
f.write(data)
downloaded_size += len(data)
current_time = time.time()
if self._download_progress_callback and (current_time - last_update_time) >= 0.1:
elapsed = current_time - start_time
speed_mbps = (downloaded_size / (1024 * 1024)) / elapsed if elapsed > 0 else 0
if total_size > 0:
progress = downloaded_size / total_size
remaining_mb = (total_size - downloaded_size) / (1024 * 1024)
if speed_mbps > 0:
eta_seconds = remaining_mb / speed_mbps
eta_str = (
f"{int(eta_seconds)}s"
if eta_seconds < 60
else f"{int(eta_seconds / 60)}m {int(eta_seconds % 60)}s"
)
else:
eta_str = "--"
status = (
f"{downloaded_size / (1024 * 1024):.1f} / "
f"{total_size / (1024 * 1024):.1f} MB • "
f"{speed_mbps:.1f} MB/s • ETA: {eta_str}"
)
else:
progress = 0
status = f"{downloaded_size / (1024 * 1024):.1f} MB • {speed_mbps:.1f} MB/s"
self._download_progress_callback(progress, speed_mbps, status)
last_update_time = current_time
logger.info(f"Download progress: {progress * 100:.1f}% - {status}")
if downloaded_size == 0:
if os.path.exists(dest_path):
os.remove(dest_path)
raise RuntimeError(
f"Downloaded 0 bytes from {url}. "
"Hugging Face may be down or blocked; try again later."
)
def _download_whispercpp_model(self):
"""Download a whisper.cpp model with progress tracking."""
import requests
@@ -1695,84 +1781,37 @@ class SpeechRecognitionManager:
raise ValueError(f"Unknown whisper.cpp model size: {self.model_size}")
url = model_info["url"]
# Prefer explicit download=true (some HF edges serve HTML without it).
if "huggingface.co" in url and "download=" not in url:
url = url + ("&" if "?" in url else "?") + "download=true"
model_path = get_model_path(self.model_size)
temp_file = model_path + ".tmp"
# Ensure directory exists
os.makedirs(os.path.dirname(model_path), exist_ok=True)
logger.info(f"Downloading whisper.cpp {self.model_size} model to {model_path}")
logger.info(f"Downloading from {url}")
try:
response = requests.get(url, stream=True)
response.raise_for_status()
total_size = int(response.headers.get("content-length", 0))
downloaded_size = 0
start_time = time.time()
last_update_time = start_time
chunk_size = 8192 # 8KB chunks
with open(temp_file, "wb") as f:
for data in response.iter_content(chunk_size=chunk_size):
if self._download_cancelled:
logger.info("Download cancelled by user")
f.close()
if os.path.exists(temp_file):
os.remove(temp_file)
raise RuntimeError("Download cancelled")
f.write(data)
downloaded_size += len(data)
# Update progress callback
current_time = time.time()
if (
self._download_progress_callback
and (current_time - last_update_time) >= 0.1
):
elapsed = current_time - start_time
if elapsed > 0:
speed_mbps = (downloaded_size / (1024 * 1024)) / elapsed
else:
speed_mbps = 0
if total_size > 0:
progress = downloaded_size / total_size
remaining_mb = (total_size - downloaded_size) / (1024 * 1024)
if speed_mbps > 0:
eta_seconds = remaining_mb / speed_mbps
eta_str = (
f"{int(eta_seconds)}s"
if eta_seconds < 60
else f"{int(eta_seconds / 60)}m {int(eta_seconds % 60)}s"
)
else:
eta_str = "--"
status = f"{downloaded_size / (1024 * 1024):.1f} / {total_size / (1024 * 1024):.1f} MB • {speed_mbps:.1f} MB/s • ETA: {eta_str}"
else:
progress = 0
status = (
f"{downloaded_size / (1024 * 1024):.1f} MB • {speed_mbps:.1f} MB/s"
)
self._download_progress_callback(progress, speed_mbps, status)
last_update_time = current_time
logger.info(f"Download progress: {progress * 100:.1f}% - {status}")
# Rename temp file to final
self._stream_model_download(url, temp_file)
os.rename(temp_file, model_path)
logger.info("whisper.cpp model downloaded successfully")
if self._download_progress_callback:
self._download_progress_callback(1.0, 0, "Complete!")
# Use RequestException when available; tests mock requests and set
# RequestException=Exception. Do not catch Timeout separately — under a
# MagicMock that is not a real exception type (TypeError in CI).
except requests.exceptions.RequestException as e:
logger.error(f"Failed to download whisper.cpp model from {url}: {e}")
if os.path.exists(temp_file):
os.remove(temp_file)
msg = str(e).lower()
if "timeout" in msg or type(e).__name__ == "Timeout":
raise RuntimeError(
"Model download timed out (Hugging Face may be slow or unavailable). "
"Check your network and try again."
) from e
raise RuntimeError(f"Failed to download whisper.cpp model: {e}") from e
except (OSError, RuntimeError, ValueError) as e:
logger.error(f"An error occurred during whisper.cpp model download: {e}")
@@ -1850,7 +1889,7 @@ class SpeechRecognitionManager:
# Download the model
logger.info(f"Downloading VOSK model from {url}")
try:
response = requests.get(url, stream=True)
response = requests.get(url, stream=True, timeout=self._MODEL_DOWNLOAD_TIMEOUT)
response.raise_for_status() # Raise an exception for bad status codes (4xx or 5xx)
total_size = int(response.headers.get("content-length", 0))
+126 -32
View File
@@ -9,6 +9,7 @@ import logging
import math
import os
import shutil
import socket
import subprocess
import threading
import time
@@ -223,47 +224,86 @@ class TextInjector:
).lower()
return not any(name in desktop for name in self._IBUS_UNBRIDGED_COMPOSITORS)
def _is_ydotoold_running(self) -> bool:
"""Return True if the ydotoold daemon appears to be running.
``ydotool type ""`` exits 0 even with no daemon (it prints a notice and
silently does nothing), so the exit code alone is not a reliable probe.
Check for the daemon's socket first, then fall back to scanning for the
process itself.
"""
socket_candidates = []
def _ydotool_socket_paths(self) -> list:
"""Return candidate Unix socket paths used by ydotoold."""
paths = []
env_socket = os.environ.get("YDOTOOL_SOCKET")
if env_socket:
socket_candidates.append(env_socket)
paths.append(env_socket)
runtime_dir = os.environ.get("XDG_RUNTIME_DIR")
if runtime_dir:
socket_candidates.append(os.path.join(runtime_dir, ".ydotool_socket"))
socket_candidates.append("/tmp/.ydotool_socket")
if any(os.path.exists(path) for path in socket_candidates):
return True
try:
result = subprocess.run(
["pgrep", "-x", "ydotoold"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=2,
)
return result.returncode == 0
except (OSError, subprocess.SubprocessError):
# pgrep missing, timed out, or otherwise unavailable -> assume not running
return False
paths.append(os.path.join(runtime_dir, ".ydotool_socket"))
paths.append("/tmp/.ydotool_socket")
return paths
def _is_ydotoold_running(self) -> bool:
"""Return True if ydotoold is accepting connections.
A leftover socket file is not enough: after a crash or a Flatpak session
exit the path can remain while nothing listens, and ydotool then fails
with exit status 2. Probe with a real connect(); remove only sockets
that nothing accepts.
ydotool 1.x uses a Unix **datagram** socket (not stream). Probing with
SOCK_STREAM fails with EPROTOTYPE and must not be treated as stale.
"""
for path in self._ydotool_socket_paths():
if not os.path.exists(path):
continue
# Try dgram first (ydotool 1.x), then stream (other clients).
connected = False
wrong_type = False
for sock_type in (socket.SOCK_DGRAM, socket.SOCK_STREAM):
sock = None
try:
sock = socket.socket(socket.AF_UNIX, sock_type)
sock.settimeout(0.5)
sock.connect(path)
connected = True
break
except OSError as e:
# Linux: EPROTOTYPE (91) / some kernels EISCONN variants when
# socket type mismatches the listening end.
if getattr(e, "errno", None) in (
getattr(socket, "EPROTOTYPE", 91),
91,
):
wrong_type = True
continue
finally:
if sock is not None:
try:
sock.close()
except OSError:
pass
if connected:
return True
if wrong_type:
# Socket exists with a type we failed to match; do not unlink —
# a live ydotoold may still be serving clients that use dgram.
# Fall through to next path.
continue
try:
os.unlink(path)
logger.info("Removed stale ydotool socket: %s", path)
except OSError:
pass
return False
def _ensure_ydotoold(self) -> bool:
"""Start ydotoold if needed so ydotool can inject via uinput.
ydotool 1.x talks to a daemon that owns /dev/uinput. Inside Flatpak we
start the daemon on demand when the app is granted device access.
Host ydotool 0.1.x often works without a daemon; starting one is still
safe when ydotoold is installed.
"""
if self._is_ydotoold_running():
return True
ydotoold = shutil.which("ydotoold")
if not ydotoold:
return False
# Distro ydotool 0.1.x may not ship a daemon; treat as ready.
return shutil.which("ydotool") is not None
if not os.path.exists("/dev/uinput"):
logger.warning(
"ydotoold needs /dev/uinput (Flatpak: grant --device=all). "
@@ -1011,14 +1051,15 @@ class TextInjector:
logger.warning("Could not copy text to clipboard for paste injection")
return False
# Simulate Ctrl+V via ydotool using evdev keycodes:
# KEY_LEFTCTRL=29, KEY_V=47; value 1=press, 0=release.
# wtype is intentionally not handled here: wtype uses the Wayland
# virtual-keyboard protocol which supports Unicode natively, so it
# never needs the clipboard-paste workaround.
# Simulate Ctrl+V via ydotool. Syntax differs by major version:
# - 0.1.x (distro packages): named sequences, e.g. ctrl+v
# - 1.x (Flatpak build): keycode:value (29=LEFTCTRL, 47=V)
# Passing 1.x codes to 0.1.x does not paste; it types garbage (e.g. "2442").
try:
cmd = self._ydotool_ctrl_v_command()
logger.debug(f"Simulating paste with: {cmd}")
subprocess.run(
["ydotool", "key", "29:1", "47:1", "47:0", "29:0"],
cmd,
check=True,
stderr=subprocess.PIPE,
text=True,
@@ -1030,6 +1071,59 @@ class TextInjector:
logger.warning(f"Paste simulation failed: {e}")
return False
# ydotool 1.x (Flatpak pins v1.0.4): KEY_LEFTCTRL=29, KEY_V=47 press/release.
_YDOTOOL_V1_CTRL_V = ["ydotool", "key", "29:1", "47:1", "47:0", "29:0"]
# ydotool 0.1.x (common distro packages): named key sequences.
_YDOTOOL_LEGACY_CTRL_V = ["ydotool", "key", "ctrl+v"]
def _ydotool_ctrl_v_command(self) -> list:
"""Return argv to simulate Ctrl+V for the installed ydotool.
ydotool 0.1.x expects ``key ctrl+v``. ydotool 1.x expects
``key 29:1 47:1 47:0 29:0`` (evdev press/release).
Flatpak always ships pinned ydotool 1.0.4 under /app, so we use the
keycode form there without probing. Host installs probe ``key --help``.
"""
cached = getattr(self, "_ydotool_ctrl_v_cmd", None)
if cached is not None:
return list(cached)
# Flatpak package pins ydotool v1.0.4 (see packaging/flatpak manifest).
ydotool_path = shutil.which("ydotool")
if not isinstance(ydotool_path, str):
ydotool_path = ""
if os.environ.get("FLATPAK_ID") or ydotool_path.startswith("/app/"):
cmd = list(self._YDOTOOL_V1_CTRL_V)
self._ydotool_ctrl_v_cmd = cmd
return list(cmd)
help_text = ""
try:
result = subprocess.run(
["ydotool", "key", "--help"],
capture_output=True,
text=True,
timeout=2,
)
help_text = f"{result.stdout or ''}{result.stderr or ''}"
except (OSError, subprocess.SubprocessError) as e:
logger.debug(f"Could not probe ydotool key --help: {e}")
# 0.1.x help: "separated by plus (+)" / examples like alt+r, CTRL+alt+f3
if "plus (+)" in help_text or "separated by plus" in help_text.lower():
cmd = list(self._YDOTOOL_LEGACY_CTRL_V)
elif ":1" in help_text or "keycode" in help_text.lower():
cmd = list(self._YDOTOOL_V1_CTRL_V)
else:
# Unknown help text: prefer named sequence (safe on 0.1.x; fails
# loudly on 1.x rather than typing digit garbage).
logger.debug("Unrecognized ydotool key --help; defaulting to legacy ctrl+v syntax")
cmd = list(self._YDOTOOL_LEGACY_CTRL_V)
self._ydotool_ctrl_v_cmd = cmd
return list(cmd)
# evdev keycodes for modifier keys. If any of these is still physically held
# when a Wayland injection fires, the injected keystrokes are modified: a
# held Alt turns the Ctrl+V paste into Ctrl+Alt+V (nothing pastes), and a
+2 -2
View File
@@ -2,8 +2,8 @@
Version information for Vocalinux.
"""
__version__ = "0.14.0-beta"
__version_info__ = (0, 14, 0, "beta")
__version__ = "0.14.1-beta"
__version_info__ = (0, 14, 1, "beta")
__author__ = "Jatin K Malik"
__email__ = "jatinkrmalik@gmail.com"
__license__ = "GPL-3.0"
+126 -3
View File
@@ -25,9 +25,7 @@ if "gi" not in sys.modules:
if "gi.repository" not in sys.modules:
sys.modules["gi.repository"] = MagicMock()
from vocalinux.speech_recognition.recognition_manager import (
SpeechRecognitionManager,
)
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
def _make_manager(engine="whisper_cpp", **kw):
@@ -161,6 +159,131 @@ class TestDownloadWhispercppModel:
with pytest.raises(RuntimeError, match="Failed to download"):
manager._download_whispercpp_model()
def test_download_whispercpp_appends_download_true(self, tmp_path):
"""Hugging Face URLs get ?download=true for reliable binary responses."""
manager = _make_manager(engine="whisper_cpp")
model_file = str(tmp_path / "ggml-small.bin")
mock_requests = MagicMock()
mock_response = MagicMock()
mock_response.headers = {"content-length": "4", "content-type": "application/octet-stream"}
mock_response.iter_content.return_value = [b"data"]
mock_requests.get.return_value = mock_response
mock_requests.exceptions.RequestException = Exception
with patch.dict("sys.modules", {"requests": mock_requests}):
with patch(
"vocalinux.speech_recognition.recognition_manager.get_model_path",
return_value=model_file,
):
manager._download_whispercpp_model()
called_url = mock_requests.get.call_args[0][0]
assert "huggingface.co" in called_url
assert "download=true" in called_url
assert mock_requests.get.call_args[1].get("timeout") == manager._MODEL_DOWNLOAD_TIMEOUT
def test_download_whispercpp_timeout_message(self, tmp_path):
"""Timeout-like errors surface a dedicated user-facing message."""
manager = _make_manager(engine="whisper_cpp")
model_file = str(tmp_path / "ggml-small.bin")
mock_requests = MagicMock()
mock_requests.get.side_effect = Exception("Read timeout")
mock_requests.exceptions.RequestException = Exception
with patch.dict("sys.modules", {"requests": mock_requests}):
with patch(
"vocalinux.speech_recognition.recognition_manager.get_model_path",
return_value=model_file,
):
with pytest.raises(RuntimeError, match="timed out"):
manager._download_whispercpp_model()
def test_stream_model_download_rejects_html(self, tmp_path):
"""HTML error pages must not be written as model binaries."""
manager = _make_manager(engine="whisper_cpp")
dest = str(tmp_path / "bad.bin")
mock_requests = MagicMock()
mock_response = MagicMock()
mock_response.headers = {"content-type": "text/html; charset=utf-8"}
mock_response.status_code = 200
mock_requests.get.return_value = mock_response
with patch.dict("sys.modules", {"requests": mock_requests}):
with pytest.raises(RuntimeError, match="HTML"):
manager._stream_model_download("https://example.com/model.bin", dest)
assert not os.path.exists(dest)
def test_stream_model_download_empty_body(self, tmp_path):
"""Zero-byte downloads are treated as failure and cleaned up."""
manager = _make_manager(engine="whisper_cpp")
dest = str(tmp_path / "empty.bin")
mock_requests = MagicMock()
mock_response = MagicMock()
mock_response.headers = {"content-length": "0", "content-type": "application/octet-stream"}
mock_response.iter_content.return_value = [b"", b""]
mock_requests.get.return_value = mock_response
with patch.dict("sys.modules", {"requests": mock_requests}):
with pytest.raises(RuntimeError, match="0 bytes"):
manager._stream_model_download("https://example.com/model.bin", dest)
assert not os.path.exists(dest)
def test_stream_model_download_cancelled(self, tmp_path):
"""User cancel mid-stream removes the partial file."""
manager = _make_manager(engine="whisper_cpp")
manager._download_cancelled = True
dest = str(tmp_path / "partial.bin")
mock_requests = MagicMock()
mock_response = MagicMock()
mock_response.headers = {
"content-length": "100",
"content-type": "application/octet-stream",
}
mock_response.iter_content.return_value = [b"chunk"]
mock_requests.get.return_value = mock_response
with patch.dict("sys.modules", {"requests": mock_requests}):
with pytest.raises(RuntimeError, match="cancelled"):
manager._stream_model_download("https://example.com/model.bin", dest)
assert not os.path.exists(dest)
def test_stream_model_download_eta_minutes_and_empty_chunks(self, tmp_path):
"""Progress path covers multi-minute ETA and skips empty chunks."""
manager = _make_manager(engine="whisper_cpp")
progress_calls = []
manager._download_progress_callback = lambda progress, speed, status: progress_calls.append(
status
)
dest = str(tmp_path / "big.bin")
mock_requests = MagicMock()
mock_response = MagicMock()
# Large total so remaining/speed yields ETA >= 60s
mock_response.headers = {
"content-length": str(100 * 1024 * 1024),
"content-type": "application/octet-stream",
}
mock_response.iter_content.return_value = [b"", b"x" * 1024]
mock_requests.get.return_value = mock_response
# start=0, then 0.2 for progress update (elapsed > 0, small speed)
times = [0.0, 0.2, 0.2]
with patch.dict("sys.modules", {"requests": mock_requests}):
with patch("time.time", side_effect=lambda: times.pop(0) if times else 1.0):
manager._stream_model_download("https://example.com/model.bin", dest)
assert os.path.exists(dest)
assert os.path.getsize(dest) == 1024
assert any("m " in s or "ETA" in s for s in progress_calls)
class TestDownloadVoskModel:
"""Test _download_vosk_model() with runtime import mocking."""
+167 -14
View File
@@ -688,6 +688,78 @@ class TestTextInjector(unittest.TestCase):
"wtype must NOT use clipboard-paste workaround",
)
@patch("vocalinux.text_injection.text_injector.subprocess.run")
@patch("vocalinux.text_injection.text_injector.shutil.which")
def test_ydotool_ctrl_v_command_legacy_uses_named_sequence(self, mock_which, mock_run):
"""Distro ydotool 0.1.x expects ctrl+v, not keycode:value."""
mock_which.return_value = "/usr/bin/ydotool"
mock_run.return_value = MagicMock(
returncode=0,
stdout="",
stderr=(
"Each key sequence can be any number of modifiers and keys, "
"separated by plus (+)\n"
),
)
injector = TextInjector.__new__(TextInjector)
with patch.dict("os.environ", {}, clear=False):
# Ensure host path (not Flatpak)
os.environ.pop("FLATPAK_ID", None)
cmd = injector._ydotool_ctrl_v_command()
self.assertEqual(cmd, ["ydotool", "key", "ctrl+v"])
self.assertEqual(injector._ydotool_ctrl_v_command(), ["ydotool", "key", "ctrl+v"])
@patch("vocalinux.text_injection.text_injector.shutil.which")
def test_ydotool_ctrl_v_command_flatpak_uses_keycodes(self, mock_which):
"""Flatpak pins ydotool 1.0.4; always use keycode:value form."""
mock_which.return_value = "/app/bin/ydotool"
injector = TextInjector.__new__(TextInjector)
with patch.dict("os.environ", {"FLATPAK_ID": "com.vocalinux.Vocalinux"}):
cmd = injector._ydotool_ctrl_v_command()
self.assertEqual(cmd, ["ydotool", "key", "29:1", "47:1", "47:0", "29:0"])
@patch("vocalinux.text_injection.text_injector.subprocess.run")
@patch("vocalinux.text_injection.text_injector.shutil.which")
def test_ydotool_ctrl_v_command_v1_help_uses_keycodes(self, mock_which, mock_run):
"""Host ydotool 1.x (no plus-sequence help) uses press/release keycodes."""
mock_which.return_value = "/usr/local/bin/ydotool"
mock_run.return_value = MagicMock(returncode=0, stdout="Usage: key N:1 N:0 ...", stderr="")
injector = TextInjector.__new__(TextInjector)
os.environ.pop("FLATPAK_ID", None)
cmd = injector._ydotool_ctrl_v_command()
self.assertEqual(cmd, ["ydotool", "key", "29:1", "47:1", "47:0", "29:0"])
@patch("vocalinux.text_injection.text_injector.subprocess.run")
@patch("vocalinux.text_injection.text_injector.shutil.which")
def test_ydotool_ctrl_v_command_unknown_help_defaults_legacy(self, mock_which, mock_run):
"""Unrecognized help text prefers named ctrl+v (safe on 0.1.x)."""
mock_which.return_value = "/usr/bin/ydotool"
mock_run.return_value = MagicMock(returncode=0, stdout="mystery help", stderr="")
injector = TextInjector.__new__(TextInjector)
os.environ.pop("FLATPAK_ID", None)
cmd = injector._ydotool_ctrl_v_command()
self.assertEqual(cmd, ["ydotool", "key", "ctrl+v"])
@patch("vocalinux.text_injection.text_injector.subprocess.run")
@patch("vocalinux.text_injection.text_injector.shutil.which")
def test_ydotool_ctrl_v_command_probe_error_defaults_legacy(self, mock_which, mock_run):
"""If key --help fails, default to legacy named sequence."""
mock_which.return_value = "/usr/bin/ydotool"
mock_run.side_effect = OSError("no ydotool")
injector = TextInjector.__new__(TextInjector)
os.environ.pop("FLATPAK_ID", None)
cmd = injector._ydotool_ctrl_v_command()
self.assertEqual(cmd, ["ydotool", "key", "ctrl+v"])
@patch("vocalinux.text_injection.text_injector.shutil.which")
def test_ydotool_ctrl_v_command_app_prefix_uses_keycodes(self, mock_which):
"""/app/bin/ydotool (Flatpak path) always uses 1.x keycodes without FLATPAK_ID."""
mock_which.return_value = "/app/bin/ydotool"
injector = TextInjector.__new__(TextInjector)
os.environ.pop("FLATPAK_ID", None)
cmd = injector._ydotool_ctrl_v_command()
self.assertEqual(cmd, ["ydotool", "key", "29:1", "47:1", "47:0", "29:0"])
@patch("vocalinux.text_injection.text_injector.shutil.which")
@patch("vocalinux.text_injection.text_injector.subprocess.run")
def test_clipboard_paste_returns_false_on_paste_failure(self, mock_run, mock_which):
@@ -1715,24 +1787,103 @@ class TestCompositorIBusBridging(unittest.TestCase):
with patch.dict("os.environ", {"XDG_CURRENT_DESKTOP": "COSMIC"}):
self.assertTrue(injector._wayland_compositor_bridges_ibus())
@patch("vocalinux.text_injection.text_injector.socket.socket")
@patch("os.path.exists", return_value=True)
def test_is_ydotoold_running_true_when_socket_exists(self, _mock_exists):
def test_is_ydotoold_running_true_when_socket_connects(self, _mock_exists, mock_socket_cls):
"""Live ydotoold socket must accept a connect() probe."""
mock_sock = MagicMock()
mock_socket_cls.return_value = mock_sock
injector = self._bare_injector()
self.assertTrue(injector._is_ydotoold_running())
mock_sock.connect.assert_called()
@patch("vocalinux.text_injection.text_injector.subprocess.run")
@patch("os.path.exists", return_value=False)
def test_is_ydotoold_running_true_when_process_found(self, _mock_exists, mock_run):
@patch("os.unlink")
@patch("vocalinux.text_injection.text_injector.socket.socket")
@patch("os.path.exists", return_value=True)
def test_is_ydotoold_running_false_and_unlinks_stale_socket(
self, _mock_exists, mock_socket_cls, mock_unlink
):
"""Stale socket files must not count as a running daemon."""
mock_sock = MagicMock()
mock_sock.connect.side_effect = ConnectionRefusedError("stale")
mock_socket_cls.return_value = mock_sock
injector = self._bare_injector()
mock_run.return_value = MagicMock(returncode=0)
self.assertTrue(injector._is_ydotoold_running())
@patch("vocalinux.text_injection.text_injector.subprocess.run")
@patch("os.path.exists", return_value=False)
def test_is_ydotoold_running_false_when_absent(self, _mock_exists, mock_run):
injector = self._bare_injector()
mock_run.return_value = MagicMock(returncode=1)
self.assertFalse(injector._is_ydotoold_running())
self.assertTrue(mock_unlink.called)
@patch("os.unlink")
@patch("vocalinux.text_injection.text_injector.socket.socket")
@patch("os.path.exists", return_value=True)
def test_is_ydotoold_running_prefers_dgram_connect(
self, _mock_exists, mock_socket_cls, mock_unlink
):
"""ydotool 1.x listens on SOCK_DGRAM; probe connects with dgram first."""
import socket as socket_mod
dgram_sock = MagicMock()
stream_sock = MagicMock()
err = OSError(91, "Protocol wrong type for socket")
err.errno = 91
stream_sock.connect.side_effect = err
def socket_factory(family, sock_type, *args, **kwargs):
if sock_type == socket_mod.SOCK_DGRAM:
return dgram_sock
return stream_sock
mock_socket_cls.side_effect = socket_factory
injector = self._bare_injector()
self.assertTrue(injector._is_ydotoold_running())
dgram_sock.connect.assert_called()
stream_sock.connect.assert_not_called()
mock_unlink.assert_not_called()
@patch("os.unlink")
@patch("vocalinux.text_injection.text_injector.socket.socket")
@patch("os.path.exists", return_value=True)
def test_is_ydotoold_running_eprototype_only_does_not_unlink(
self, _mock_exists, mock_socket_cls, mock_unlink
):
"""If only EPROTOTYPE is seen, do not delete a possibly live socket."""
err = OSError(91, "Protocol wrong type for socket")
err.errno = 91
mock_sock = MagicMock()
mock_sock.connect.side_effect = err
mock_socket_cls.return_value = mock_sock
injector = self._bare_injector()
self.assertFalse(injector._is_ydotoold_running())
mock_unlink.assert_not_called()
@patch("os.path.exists", return_value=False)
def test_is_ydotoold_running_false_when_absent(self, _mock_exists):
injector = self._bare_injector()
self.assertFalse(injector._is_ydotoold_running())
@patch("vocalinux.text_injection.text_injector.shutil.which")
def test_ensure_ydotoold_ready_when_only_ydotool_cli(self, mock_which):
"""Host ydotool 0.1.x without ydotoold is still considered ready."""
mock_which.side_effect = lambda cmd: "/usr/bin/ydotool" if cmd == "ydotool" else None
injector = self._bare_injector()
with patch.object(injector, "_is_ydotoold_running", return_value=False):
self.assertTrue(injector._ensure_ydotoold())
@patch("os.path.exists", return_value=False)
@patch("vocalinux.text_injection.text_injector.shutil.which", return_value="/app/bin/ydotoold")
def test_ensure_ydotoold_false_without_uinput(self, _mock_which, _mock_exists):
injector = self._bare_injector()
with patch.object(injector, "_is_ydotoold_running", return_value=False):
self.assertFalse(injector._ensure_ydotoold())
@patch("vocalinux.text_injection.text_injector.subprocess.Popen")
@patch("os.path.exists", return_value=True)
@patch("vocalinux.text_injection.text_injector.shutil.which", return_value="/app/bin/ydotoold")
def test_ensure_ydotoold_starts_daemon(self, _mock_which, _mock_exists, mock_popen):
injector = self._bare_injector()
# First probe: not running; after start: running
with patch.object(injector, "_is_ydotoold_running", side_effect=[False, False, True]):
with patch("time.sleep"):
self.assertTrue(injector._ensure_ydotoold())
mock_popen.assert_called_once()
@patch("vocalinux.text_injection.text_injector.is_ibus_daemon_running", return_value=True)
@patch("vocalinux.text_injection.text_injector.is_ibus_active_input_method", return_value=True)
@@ -1770,11 +1921,13 @@ class TestCompositorIBusBridging(unittest.TestCase):
mock_ibus_class.assert_not_called()
def test_is_ydotoold_running_true_when_socket_env_set(self):
"""An explicit YDOTOOL_SOCKET pointing at an existing socket counts as running."""
"""An explicit YDOTOOL_SOCKET that accepts connect() counts as running."""
injector = self._bare_injector()
with patch.dict("os.environ", {"YDOTOOL_SOCKET": "/run/user/1000/.ydotool_socket"}):
with patch("os.path.exists", return_value=True):
self.assertTrue(injector._is_ydotoold_running())
with patch("vocalinux.text_injection.text_injector.socket.socket") as mock_cls:
mock_cls.return_value = MagicMock()
self.assertTrue(injector._is_ydotoold_running())
@patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=False)
@patch("vocalinux.text_injection.text_injector.shutil.which")
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "vocalinux-website",
"version": "0.14.0",
"version": "0.14.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "vocalinux-website",
"version": "0.14.0",
"version": "0.14.1",
"dependencies": {
"@types/node": "^20.14.10",
"@types/react": "^18.3.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "vocalinux-website",
"version": "0.14.0",
"version": "0.14.1",
"private": true,
"type": "module",
"scripts": {
+13
View File
@@ -14,6 +14,19 @@ import { SeoSubpageShell } from "@/components/seo-subpage-shell";
import { absoluteUrl, buildPageMetadata } from "@/lib/seo";
const releases = [
{
version: "v0.14.1-beta",
date: "2026-07-16",
type: "beta",
highlights: [
"Flatpak packaging for universal distribution: whisper.cpp engine, XDG sandbox paths, global hotkeys via evdev, Wayland text injection via wl-copy + ydotool (PR #484, closes #167)",
"AUR package and CI publish path for Arch Linux (PR #518)",
"Layout-aware combo keys so custom shortcuts work on non-US keyboard layouts (PR #514)",
"Installer fix for sg not found on Ubuntu 26.04 / Debian 13 (PR #524)",
"Text injection treats XIM none as unset (PR #512)",
"Website screenshot gallery refresh and Dependabot npm alert fixes (PR #521, #515)",
],
},
{
version: "v0.14.0-beta",
date: "2026-07-13",
+2 -2
View File
@@ -68,7 +68,7 @@ const homeJsonLd = [
},
description:
"Offline voice dictation and speech-to-text for Linux with whisper.cpp and VOSK.",
softwareVersion: "0.14.0-beta",
softwareVersion: "0.14.1-beta",
author: {
"@type": "Person",
name: "Jatin K Malik",
@@ -341,7 +341,7 @@ export default function HomePage() {
/>
<span className="text-lg font-bold sm:text-xl">Vocalinux</span>
<span className="from-primary/20 border-primary/30 shadow-primary/20 hidden rounded-full border bg-gradient-to-r to-green-500/20 px-2.5 py-1 text-xs font-semibold text-primary shadow-sm sm:inline-block">
v0.14.0 Beta
v0.14.1 Beta
</span>
</Link>