diff --git a/README.md b/README.md index 0a60993..1e5bbba 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/docs/RELEASE_PROCESS.md b/docs/RELEASE_PROCESS.md index 37a2977..c23abaa 100644 --- a/docs/RELEASE_PROCESS.md +++ b/docs/RELEASE_PROCESS.md @@ -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? diff --git a/docs/UPDATE.md b/docs/UPDATE.md index 3cc3f99..cbd7296 100644 --- a/docs/UPDATE.md +++ b/docs/UPDATE.md @@ -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 ``` diff --git a/install.sh b/install.sh index 35b2816..6f3f174 100755 --- a/install.sh +++ b/install.sh @@ -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 diff --git a/packaging/aur/vocalinux/PKGBUILD b/packaging/aur/vocalinux/PKGBUILD index 0fd7aae..6f6f86b 100644 --- a/packaging/aur/vocalinux/PKGBUILD +++ b/packaging/aur/vocalinux/PKGBUILD @@ -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') diff --git a/packaging/flatpak/README.md b/packaging/flatpak/README.md index 0024d06..c9971f9 100644 --- a/packaging/flatpak/README.md +++ b/packaging/flatpak/README.md @@ -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: ``` diff --git a/packaging/flatpak/com.vocalinux.Vocalinux.metainfo.xml b/packaging/flatpak/com.vocalinux.Vocalinux.metainfo.xml index 4b2436b..78de51d 100644 --- a/packaging/flatpak/com.vocalinux.Vocalinux.metainfo.xml +++ b/packaging/flatpak/com.vocalinux.Vocalinux.metainfo.xml @@ -105,6 +105,11 @@ + + +

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.

+
+

Vocalinux 0.14.0-beta as Flatpak: whisper.cpp with Vulkan, XDG config/models/logs, XWayland text injection, and system tray integration.

diff --git a/packaging/flatpak/com.vocalinux.Vocalinux.yml b/packaging/flatpak/com.vocalinux.Vocalinux.yml index be73a46..a5f34f8 100644 --- a/packaging/flatpak/com.vocalinux.Vocalinux.yml +++ b/packaging/flatpak/com.vocalinux.Vocalinux.yml @@ -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. diff --git a/src/vocalinux/speech_recognition/recognition_manager.py b/src/vocalinux/speech_recognition/recognition_manager.py index da12a93..93cef5c 100644 --- a/src/vocalinux/speech_recognition/recognition_manager.py +++ b/src/vocalinux/speech_recognition/recognition_manager.py @@ -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)) diff --git a/src/vocalinux/text_injection/text_injector.py b/src/vocalinux/text_injection/text_injector.py index c03501c..3f5f682 100644 --- a/src/vocalinux/text_injection/text_injector.py +++ b/src/vocalinux/text_injection/text_injector.py @@ -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 diff --git a/src/vocalinux/version.py b/src/vocalinux/version.py index 37966e8..15ea0b6 100644 --- a/src/vocalinux/version.py +++ b/src/vocalinux/version.py @@ -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" diff --git a/tests/test_recognition_manager_downloads.py b/tests/test_recognition_manager_downloads.py index 6027bb8..59af5e1 100644 --- a/tests/test_recognition_manager_downloads.py +++ b/tests/test_recognition_manager_downloads.py @@ -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.""" diff --git a/tests/test_text_injector.py b/tests/test_text_injector.py index bf26fb7..707f9c7 100644 --- a/tests/test_text_injector.py +++ b/tests/test_text_injector.py @@ -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") diff --git a/web/package-lock.json b/web/package-lock.json index 569250d..8fbb622 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index f4028cf..099602e 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "vocalinux-website", - "version": "0.14.0", + "version": "0.14.1", "private": true, "type": "module", "scripts": { diff --git a/web/src/app/changelog/page.tsx b/web/src/app/changelog/page.tsx index a37abc8..b6defc9 100644 --- a/web/src/app/changelog/page.tsx +++ b/web/src/app/changelog/page.tsx @@ -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", diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index aa9e26a..1cda2d0 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -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() { /> Vocalinux - v0.14.0 Beta + v0.14.1 Beta