Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61e8206c67 | ||
|
|
594686d0f7 | ||
|
|
808bd17704 | ||
|
|
d1a80e6ead | ||
|
|
8b842e5712 |
+7
-1
@@ -12,7 +12,13 @@ Release history for Vocalinux.
|
|||||||
|
|
||||||
## Current stable
|
## Current stable
|
||||||
|
|
||||||
**[v0.19.0](https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.19.0)** (2026-11-07)
|
**[v0.19.1](https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.19.1)** (2026-11-07)
|
||||||
|
|
||||||
|
A patch on 0.19.0. A leftover upstream launcher (`vocalinux.desktop`, `vocalinux`, `vocalinux-gui`) is now reported at startup, because it hid the Voculinux entry in the launcher (#52, #54). The release pipeline docs now say `bash` for `publish-release.sh`.
|
||||||
|
|
||||||
|
### v0.19.0 (2026-11-07)
|
||||||
|
|
||||||
|
[Release page](https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.19.0)
|
||||||
|
|
||||||
The first Voculinux release: a hard fork of 0.18.5 for Void Linux (glibc, x86_64), published as a signed xbps repository from the fork's Gitea host. Changes since v0.18.5, from the commit subjects and PR titles on `main`:
|
The first Voculinux release: a hard fork of 0.18.5 for Void Linux (glibc, x86_64), published as a signed xbps repository from the fork's Gitea host. Changes since v0.18.5, from the commit subjects and PR titles on `main`:
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
[](https://www.gnu.org/licenses/agpl-3.0)
|
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||||
[](#privacy-and-security)
|
[](#privacy-and-security)
|
||||||
[](#features)
|
[](#features)
|
||||||
[](https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.19.0)
|
[](https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.19.1)
|
||||||
|
|
||||||
[Website](https://voculinux.com) · [Install](#install) · [Docs](#documentation) · [Releases](https://git.bongbetic.com/xavierk/voculinux/releases) · [Issues](https://git.bongbetic.com/xavierk/voculinux/issues)
|
[Website](https://voculinux.com) · [Install](#install) · [Docs](#documentation) · [Releases](https://git.bongbetic.com/xavierk/voculinux/releases) · [Issues](https://git.bongbetic.com/xavierk/voculinux/issues)
|
||||||
|
|
||||||
@@ -19,16 +19,16 @@
|
|||||||
|
|
||||||
Voculinux turns speech into typed text in whatever app has focus. It is a free, AGPL-3.0-licensed desktop app for X11 and Wayland. After you download a model, local engines (whisper.cpp by default, plus OpenAI Whisper, Faster Whisper, VOSK, and Parakeet) run speech-to-text on your machine. An optional remote HTTP API is off unless you configure it.
|
Voculinux turns speech into typed text in whatever app has focus. It is a free, AGPL-3.0-licensed desktop app for X11 and Wayland. After you download a model, local engines (whisper.cpp by default, plus OpenAI Whisper, Faster Whisper, VOSK, and Parakeet) run speech-to-text on your machine. An optional remote HTTP API is off unless you configure it.
|
||||||
|
|
||||||
No account is required. Models download once. After that, speech-to-text stays on your machine.
|
No account is required. Models download once. After that, speech-to-text stays on your machine. It is built for Void Linux, with niri and the Dank Material Shell in mind, and is inspired by an earlier project credited under [Origin](#origin).
|
||||||
|
|
||||||
**Current release:** [v0.19.0](https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.19.0). The first Voculinux release: a Void Linux package from the [signed xbps repository](#void-linux-signed-xbps-repo), niri and Dank Material Shell support, a model default chosen by free GPU memory, and a fix that keeps system-audio capture from ever opening the microphone. Details: [docs/UPDATE.md](docs/UPDATE.md).
|
**Current release:** [v0.19.1](https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.19.1), a patch on v0.19.0 that warns when a leftover launcher from the upstream project shadows Voculinux. v0.19.0 was the first Voculinux release: a Void Linux package from the [signed xbps repository](#void-linux-signed-xbps-repo), niri and Dank Material Shell support, a model default chosen by free GPU memory, and a fix that keeps system-audio capture from ever opening the microphone. Details: [docs/UPDATE.md](docs/UPDATE.md).
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **On-device after model download**: Local engines; speech-to-text stays on your machine
|
- **On-device after model download**: Local engines; speech-to-text stays on your machine
|
||||||
- **X11 and Wayland**: Text injection via xdotool, IBus, wtype, ydotool, or clipboard fallback
|
- **X11 and Wayland**: Text injection via xdotool, IBus, wtype, ydotool, or clipboard fallback
|
||||||
- **Several engines**: whisper.cpp (default), OpenAI Whisper, Faster Whisper, VOSK, Parakeet, plus optional remote HTTP API or local gateway from Settings (not on-device)
|
- **Several engines**: whisper.cpp (default), OpenAI Whisper, Faster Whisper, VOSK, Parakeet, plus optional remote HTTP API or local gateway from Settings (not on-device)
|
||||||
- **GPU acceleration**: Vulkan for AMD, Intel, and NVIDIA with whisper.cpp
|
- **GPU acceleration**: Vulkan for AMD, Intel, and NVIDIA with whisper.cpp, with a CPU fallback. CUDA is a local-build option, not part of the package; see [GPU and CUDA](#gpu-and-cuda)
|
||||||
- **Toggle or push-to-talk**: New installs default to hold Right Alt; existing configs keep their shortcut
|
- **Toggle or push-to-talk**: New installs default to hold Right Alt; existing configs keep their shortcut
|
||||||
- **System tray + settings**: Searchable sidebar, Speech Model simple setup with Advanced as an island, status icons, audio feedback
|
- **System tray + settings**: Searchable sidebar, Speech Model simple setup with Advanced as an island, status icons, audio feedback
|
||||||
- **Start on login**: XDG autostart (desktop session, not a systemd service)
|
- **Start on login**: XDG autostart (desktop session, not a systemd service)
|
||||||
@@ -139,6 +139,13 @@ echo 'repository=https://git.bongbetic.com/xavierk/voculinux/releases/download/l
|
|||||||
xbps-install -S voculinux
|
xbps-install -S voculinux
|
||||||
```
|
```
|
||||||
|
|
||||||
|
After that, Voculinux upgrades with the rest of the system:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo xbps-install -Su # upgrades Voculinux together with everything else
|
||||||
|
xbps-query -R voculinux # shows the version the repository serves
|
||||||
|
```
|
||||||
|
|
||||||
The key file is named by its fingerprint, so xbps finds it on the first
|
The key file is named by its fingerprint, so xbps finds it on the first
|
||||||
sync and asks nothing. If xbps still shows a fingerprint prompt, answer
|
sync and asks nothing. If xbps still shows a fingerprint prompt, answer
|
||||||
`N` unless it reads exactly the fingerprint above (it must also match the
|
`N` unless it reads exactly the fingerprint above (it must also match the
|
||||||
@@ -149,8 +156,8 @@ sync: it would accept an unknown key without asking.
|
|||||||
|
|
||||||
| Engine | When to use |
|
| Engine | When to use |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| **whisper.cpp** (default) | Best default; Vulkan GPU on AMD, Intel, and NVIDIA |
|
| **whisper.cpp** (default) | Best default; Vulkan GPU on AMD, Intel, and NVIDIA, CPU otherwise |
|
||||||
| **Whisper** (OpenAI) | PyTorch path; NVIDIA/CUDA |
|
| **Whisper** (OpenAI) | PyTorch path; NVIDIA/CUDA with a CUDA PyTorch you install yourself |
|
||||||
| **Faster Whisper** | CPU-friendly Whisper via CTranslate2 / INT8 |
|
| **Faster Whisper** | CPU-friendly Whisper via CTranslate2 / INT8 |
|
||||||
| **VOSK** | Low RAM / minimal footprint |
|
| **VOSK** | Low RAM / minimal footprint |
|
||||||
| **Parakeet** | CPU; NVIDIA NeMo ASR via sherpa-onnx; 25 European languages |
|
| **Parakeet** | CPU; NVIDIA NeMo ASR via sherpa-onnx; 25 European languages |
|
||||||
@@ -164,10 +171,32 @@ on first run.
|
|||||||
### After installation
|
### After installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
voculinux # from the xbps package, on your PATH
|
voculinux # start the tray app; it also appears in your application menu
|
||||||
|
voculinux --toggle # start or stop dictation from a keybind or script
|
||||||
```
|
```
|
||||||
|
|
||||||
You can also launch Voculinux from your application menu.
|
1. **First run:** the tray icon appears first, then the speech model loads in the
|
||||||
|
background. If no model is present, it downloads once (a few hundred MB to
|
||||||
|
about 1 GB, checksum-verified). The default is chosen from your free memory:
|
||||||
|
`large-v3-turbo-q8_0`, then `q5_0`, then `small` as memory gets tighter.
|
||||||
|
2. **Dictate:** focus any text field, hold **Right Alt**, speak, release. The text is
|
||||||
|
typed at the cursor.
|
||||||
|
3. **Hands-free:** switch to toggle mode in Settings, or bind `voculinux --toggle`.
|
||||||
|
4. **On niri:** copy the example keybind pack and read [docs/NIRI.md](docs/NIRI.md).
|
||||||
|
Typing goes straight to the cursor (`wtype`), with no permission prompt. The tray
|
||||||
|
needs a StatusNotifier host, which Dank Material Shell provides.
|
||||||
|
5. **Something wrong?** Run `voculinux --debug`, then see
|
||||||
|
[Troubleshooting](docs/TROUBLESHOOTING.md).
|
||||||
|
|
||||||
|
Quit and relaunch Voculinux after an upgrade so the running tray uses the new version.
|
||||||
|
|
||||||
|
### Leftover launchers from an earlier install
|
||||||
|
|
||||||
|
Launchers left by an earlier install of the upstream project can point at a Python
|
||||||
|
environment that no longer exists. They start nothing, yet they appear next to
|
||||||
|
Voculinux in the application menu. Voculinux logs and shows one notice that names
|
||||||
|
those files. It never deletes or changes them, so remove the ones it names
|
||||||
|
yourself. Keep any launcher that still starts a working install.
|
||||||
|
|
||||||
### From a source checkout (developers)
|
### From a source checkout (developers)
|
||||||
|
|
||||||
@@ -186,10 +215,35 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for the two-venv layout and the dev workf
|
|||||||
| **OS** | Linux. The published package targets Void Linux (glibc, x86_64) |
|
| **OS** | Linux. The published package targets Void Linux (glibc, x86_64) |
|
||||||
| **Python** | 3.11 or newer |
|
| **Python** | 3.11 or newer |
|
||||||
| **Display** | X11 or Wayland |
|
| **Display** | X11 or Wayland |
|
||||||
| **Hardware** | Microphone; GPU optional (Vulkan) |
|
| **Hardware** | Microphone; GPU optional (Vulkan, any vendor) |
|
||||||
|
|
||||||
Python 3.11+ is required, with PyGObject from the system (`python3-gi` or the Void `python3-gobject` package). Ubuntu 22.04 (Python 3.10) and Debian 11 (3.9) are below that floor. Source-checkout distribution notes: [docs/DISTRO_COMPATIBILITY.md](docs/DISTRO_COMPATIBILITY.md).
|
Python 3.11+ is required, with PyGObject from the system (`python3-gi` or the Void `python3-gobject` package). Ubuntu 22.04 (Python 3.10) and Debian 11 (3.9) are below that floor. Source-checkout distribution notes: [docs/DISTRO_COMPATIBILITY.md](docs/DISTRO_COMPATIBILITY.md).
|
||||||
|
|
||||||
|
## GPU and CUDA
|
||||||
|
|
||||||
|
Voculinux is not a CUDA build. The published Void package accelerates whisper.cpp
|
||||||
|
with **Vulkan**, plus CPU code paths, and it does not ship CUDA. Void has no CUDA
|
||||||
|
toolkit package, so the package cannot be built against one.
|
||||||
|
|
||||||
|
| Where | What runs | Notes |
|
||||||
|
|-------|-----------|-------|
|
||||||
|
| Published Void package, NVIDIA GPU | Vulkan | Needs the NVIDIA driver, which provides the Vulkan device. This is the supported NVIDIA path, tested on an RTX 2050 (4 GB) |
|
||||||
|
| Published Void package, AMD or Intel GPU | Vulkan | Needs the Mesa Vulkan driver. Software Vulkan devices are skipped |
|
||||||
|
| Published Void package, no usable GPU | CPU | The best instruction-set variant (AVX2 and others) is loaded at run time |
|
||||||
|
| Local rebuild of `pywhispercpp` with `GGML_CUDA=1` and a toolkit you installed by hand | CUDA | Works, but unsupported: you maintain it, and the package will not replace it on upgrade without a fresh rebuild |
|
||||||
|
| OpenAI Whisper engine (PyTorch) in your own Python environment | CUDA | Not included in the package; you install a CUDA PyTorch yourself |
|
||||||
|
| Faster Whisper engine in your own Python environment | CPU (INT8), CUDA optional | Not included in the package |
|
||||||
|
|
||||||
|
How the whisper.cpp engine decides: it uses the backends the installed
|
||||||
|
`pywhispercpp` was actually built with, not what the machine could run. When a
|
||||||
|
CUDA-built `pywhispercpp` is installed and a complete toolkit is found (`nvcc`,
|
||||||
|
the runtime header and `libcudart`, with GPU compute capability 7.0 or newer),
|
||||||
|
CUDA ranks first; otherwise the order is Vulkan, then CPU. The model size then
|
||||||
|
follows the free memory of the chosen backend, with its own thresholds (CUDA needs
|
||||||
|
somewhat more than Vulkan for the same model, per the measurements in
|
||||||
|
[docs/PERF_BASELINE_PROTOCOL.md](docs/PERF_BASELINE_PROTOCOL.md)). To see which
|
||||||
|
backend loaded, run `voculinux --debug` and read the startup log.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### Dictation
|
### Dictation
|
||||||
@@ -263,7 +317,7 @@ Neural VAD (Silero) is used when `onnxruntime` is available. The xbps package do
|
|||||||
|
|
||||||
## Privacy and security
|
## Privacy and security
|
||||||
|
|
||||||
- Local engines process audio on-device after you download a model; no Voca account required
|
- Local engines process audio on-device after you download a model; no account required
|
||||||
- Optional remote API is off by default and only used when you configure a server
|
- Optional remote API is off by default and only used when you configure a server
|
||||||
- Model downloads are checked against pinned checksums
|
- Model downloads are checked against pinned checksums
|
||||||
|
|
||||||
@@ -277,7 +331,7 @@ cd voculinux
|
|||||||
./install.sh --dev
|
./install.sh --dev
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
pytest
|
pytest
|
||||||
python -m vocalinux.main --debug
|
voculinux --debug
|
||||||
```
|
```
|
||||||
|
|
||||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for the two-venv layout, `just` recipes, and PR guidelines.
|
See [CONTRIBUTING.md](CONTRIBUTING.md) for the two-venv layout, `just` recipes, and PR guidelines.
|
||||||
@@ -293,10 +347,10 @@ Planned:
|
|||||||
|
|
||||||
## Origin
|
## Origin
|
||||||
|
|
||||||
Voculinux is a hard fork of Vocalinux by VocaHQ: https://github.com/VocaHQ/vocalinux
|
Voculinux is inspired by Vocalinux by VocaHQ: https://github.com/VocaHQ/vocalinux
|
||||||
|
|
||||||
Voculinux is that project's fork for Void Linux. Its source, issues and releases
|
It is an independent project for Void Linux. Its source, issues and releases live
|
||||||
live on the fork's own host.
|
on the fork's own host.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ User and maintainer documentation for Voculinux.
|
|||||||
| [TROUBLESHOOTING.md](TROUBLESHOOTING.md) | Users | Common install and runtime failures |
|
| [TROUBLESHOOTING.md](TROUBLESHOOTING.md) | Users | Common install and runtime failures |
|
||||||
| [USER_GUIDE.md](USER_GUIDE.md) | Users | Dictation, engines, models, shortcuts, tips |
|
| [USER_GUIDE.md](USER_GUIDE.md) | Users | Dictation, engines, models, shortcuts, tips |
|
||||||
| [NIRI.md](NIRI.md) | Users | niri keybind pack and Dank Material Shell notes |
|
| [NIRI.md](NIRI.md) | Users | niri keybind pack and Dank Material Shell notes |
|
||||||
|
| [cuda-vocu.md](cuda-vocu.md) | Maintainers | Plan: optional CUDA backend in the Void repository |
|
||||||
| [UPDATE.md](UPDATE.md) | Users | How to upgrade; release notes by version |
|
| [UPDATE.md](UPDATE.md) | Users | How to upgrade; release notes by version |
|
||||||
| [DISTRO_COMPATIBILITY.md](DISTRO_COMPATIBILITY.md) | Users | Distro matrix, session caveats |
|
| [DISTRO_COMPATIBILITY.md](DISTRO_COMPATIBILITY.md) | Users | Distro matrix, session caveats |
|
||||||
| [HTTP_REMOTE.md](HTTP_REMOTE.md) | Power users | Remote HTTP transcription |
|
| [HTTP_REMOTE.md](HTTP_REMOTE.md) | Power users | Remote HTTP transcription |
|
||||||
|
|||||||
@@ -574,10 +574,10 @@ from a clean checkout of the tag, in this order. Replace `vX.Y.Z` with the relea
|
|||||||
that is not the committed one, both before any container starts. It writes `sdist/`,
|
that is not the committed one, both before any container starts. It writes `sdist/`,
|
||||||
`xbps/`, `SDIST_SHA256` and `BUILT_FROM` (the tag and the commit it was built from), and
|
`xbps/`, `SDIST_SHA256` and `BUILT_FROM` (the tag and the commit it was built from), and
|
||||||
prints `checksum=<sha256>`. Remove the key file afterwards.
|
prints `checksum=<sha256>`. Remove the key file afterwards.
|
||||||
5. **Dry-run the publish:** `packaging/void/publish-release.sh vX.Y.Z --out-dir /path/to/out --dry-run`.
|
5. **Dry-run the publish:** `bash packaging/void/publish-release.sh vX.Y.Z --out-dir /path/to/out --dry-run`.
|
||||||
It runs every check (the tag is on `origin` at the built commit, the digest, the key)
|
It runs every check (the tag is on `origin` at the built commit, the digest, the key)
|
||||||
and prints the API calls it would make. It sends no request and reads no token.
|
and prints the API calls it would make. It sends no request and reads no token.
|
||||||
6. **Publish:** `packaging/void/publish-release.sh vX.Y.Z --out-dir /path/to/out`. It attaches the
|
6. **Publish:** `bash packaging/void/publish-release.sh vX.Y.Z --out-dir /path/to/out`. It attaches the
|
||||||
sdist, packages, signatures, `FINGERPRINT` and the client `.plist` to the tag's release, then
|
sdist, packages, signatures, `FINGERPRINT` and the client `.plist` to the tag's release, then
|
||||||
replaces the assets of the rolling `latest` release that the client registration URL serves.
|
replaces the assets of the rolling `latest` release that the client registration URL serves.
|
||||||
The repodata is uploaded last. The token comes from `GITEA_TOKEN`, or from the `tea` login
|
The repodata is uploaded last. The token comes from `GITEA_TOKEN`, or from the `tea` login
|
||||||
@@ -642,6 +642,7 @@ git push origin v0.5.1-beta
|
|||||||
|
|
||||||
| Version | Date | Phase | Notes |
|
| Version | Date | Phase | Notes |
|
||||||
|---------|------|-------|-------|
|
|---------|------|-------|-------|
|
||||||
|
| 0.19.1 | 2026-11-07 | Stable | Patch: a leftover upstream Vocalinux launcher is reported at startup (#52) |
|
||||||
| 0.19.0 | 2026-11-07 | Stable | First Voculinux release: a hard fork for Void Linux (glibc, x86_64) with a signed xbps repository, niri and Dank Material Shell support, a per-backend memory-aware model default, and PipeWire system audio that never falls back to the microphone |
|
| 0.19.0 | 2026-11-07 | Stable | First Voculinux release: a hard fork for Void Linux (glibc, x86_64) with a signed xbps repository, niri and Dank Material Shell support, a per-backend memory-aware model default, and PipeWire system audio that never falls back to the microphone |
|
||||||
| 0.2.0-alpha | 2024 | Alpha | Initial alpha |
|
| 0.2.0-alpha | 2024 | Alpha | Initial alpha |
|
||||||
| 0.3.0-alpha | 2024 | Alpha | - |
|
| 0.3.0-alpha | 2024 | Alpha | - |
|
||||||
|
|||||||
+10
-1
@@ -25,7 +25,7 @@ installer as below.
|
|||||||
```bash
|
```bash
|
||||||
cd voculinux
|
cd voculinux
|
||||||
git fetch origin
|
git fetch origin
|
||||||
git checkout v0.19.0
|
git checkout v0.19.1
|
||||||
./install.sh
|
./install.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -90,6 +90,15 @@ Missing system packages: see [INSTALL.md](INSTALL.md) or [DISTRO_COMPATIBILITY.m
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## What's New in v0.19.1
|
||||||
|
|
||||||
|
0.19.1 is a patch on 0.19.0 for Void Linux. If a launcher from the upstream project
|
||||||
|
is still installed, Voculinux now says so
|
||||||
|
on startup instead of leaving a stale upstream entry in the launcher. It does not remove
|
||||||
|
the files; the startup message names them. Update with `sudo xbps-install -Su`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## What's New in v0.19.0
|
## What's New in v0.19.0
|
||||||
|
|
||||||
0.19.0 is the first Voculinux release: a hard fork of the 0.18.5 line for Void Linux
|
0.19.0 is the first Voculinux release: a hard fork of the 0.18.5 line for Void Linux
|
||||||
|
|||||||
@@ -0,0 +1,365 @@
|
|||||||
|
# CUDA for Voculinux on Void Linux: integration plan
|
||||||
|
|
||||||
|
Status: plan, not started. Target release: 0.20.0. Owner decisions taken 2026-11-07 (section 9);
|
||||||
|
phase 0 is complete.
|
||||||
|
|
||||||
|
This plan adds an optional CUDA backend for the whisper.cpp engine to the signed Void
|
||||||
|
xbps repository, without changing what users who do not opt in receive. Each phase
|
||||||
|
lists the files it touches, the tests that gate it, and its acceptance check, so it can
|
||||||
|
be split into tickets as written (section 8).
|
||||||
|
|
||||||
|
## 1. Goal and non-goals
|
||||||
|
|
||||||
|
Goal: `sudo xbps-install -S voculinux-cuda` on Void Linux (glibc, x86_64) with an NVIDIA
|
||||||
|
GPU of compute capability 7.5 or newer gives whisper.cpp a CUDA backend. The app
|
||||||
|
selects it automatically and falls back to Vulkan, then CPU, when it cannot run.
|
||||||
|
|
||||||
|
Non-goals:
|
||||||
|
|
||||||
|
- No change to the default `voculinux` package. It stays Vulkan plus CPU and pulls in
|
||||||
|
nothing from NVIDIA.
|
||||||
|
- No CUDA for the optional Python engines (OpenAI Whisper, Faster Whisper). They stay in
|
||||||
|
the user's own Python environment.
|
||||||
|
- No musl, no aarch64. Voculinux publishes glibc x86_64 only.
|
||||||
|
- No user-installed toolkit. Users never install `nvcc` or a CUDA toolkit.
|
||||||
|
|
||||||
|
## 2. Facts this plan is built on
|
||||||
|
|
||||||
|
Measured or checked on 2026-11-07, on the maintainer host (Void, RTX 2050 4 GB, compute
|
||||||
|
capability 8.6, driver 595.112.07, local CUDA 13.2 toolkit):
|
||||||
|
|
||||||
|
| Fact | Value | Consequence |
|
||||||
|
|------|-------|-------------|
|
||||||
|
| Void CUDA toolkit package | none, in main or nonfree (`xbps-query -Rs cuda` is empty) | the toolkit must come from NVIDIA's redistributable archives, at build time only |
|
||||||
|
| NVIDIA driver library `libcuda.so.1` | in Void nonfree package `nvidia-libs` | the CUDA package depends on `nvidia-libs`; users need the nonfree repo |
|
||||||
|
| Performance on RTX 2050, `large-v3-turbo-q8_0`, warm real-time factor | Vulkan 0.013, CUDA 0.016 (local build) | CUDA is not faster on this card. Value is for larger NVIDIA GPUs, still unmeasured |
|
||||||
|
| VRAM to prefer q8_0 / q5_0 | Vulkan 1644 / 1359 MiB, CUDA 1690 / 1404 MiB | already in `model_ladder.py` (`_PREFER_ABOVE_MIB`) |
|
||||||
|
| `libggml-cuda.so` from the local build (sm_86 only) | 57 MiB, 36 MiB zstd | one architecture; a multi-architecture build is larger (phase 1 measures it) |
|
||||||
|
| `libcublasLt.so.13` / `libcublas.so.13` / `libcudart.so.13` | 482 / 52 / 0.7 MiB, about 365 MiB zstd together | the runtime package is large; check the release upload limit (phase 0) |
|
||||||
|
| Gitea `[attachment] MAX_SIZE` reported by `/api/v1/settings/attachment` | 100 MB | that is the issue-attachment limit; the release-asset limit must be checked separately (phase 0) |
|
||||||
|
| Host GCC | 14.2.1 | nvcc 13.2 must accept the container's GCC without `-allow-unsupported-compiler` (phase 1 checks) |
|
||||||
|
| CUDA EULA, Attachment A | `libcudart.so`, `libcublas.so`, `libcublasLt.so` are redistributable with an application, unmodified; `nvcc` is not | ship those three libraries, never the compiler |
|
||||||
|
|
||||||
|
The backend is a loadable library. The `python3-pywhispercpp` template already builds
|
||||||
|
whisper.cpp with `GGML_BACKEND_DL=ON`, and the carried patch
|
||||||
|
`packaging/void/srcpkgs/python3-pywhispercpp/patches/load-ggml-backends-beside-module.patch`
|
||||||
|
loads every `libggml-*.so` that sits beside the extension. A `libggml-cuda.so` dropped into
|
||||||
|
that directory is picked up without changing the extension. This is what makes a separate,
|
||||||
|
optional CUDA package possible.
|
||||||
|
|
||||||
|
## 3. Target package layout
|
||||||
|
|
||||||
|
| Package | New | Contents | Depends on |
|
||||||
|
|---------|-----|----------|------------|
|
||||||
|
| `python3-pywhispercpp` | no | extension, `libggml-base`, CPU variants, `libggml-vulkan.so` (unchanged) | as today |
|
||||||
|
| `voculinux-cuda-libs` | yes | the unmodified `libcudart.so.13`, `libcublas.so.13`, `libcublasLt.so.13` in the private directory `/usr/lib/voculinux/cuda/`, plus the NVIDIA EULA as its license | nothing |
|
||||||
|
| `python3-pywhispercpp-cuda` | yes | only `libggml-cuda.so`, installed beside the extension in `${py3_sitelib}` | `python3-pywhispercpp-${version}_${revision}` (exact), `voculinux-cuda-libs`, `nvidia-libs` |
|
||||||
|
| `voculinux-cuda` | yes | meta package, no files | `voculinux`, `python3-pywhispercpp-cuda` |
|
||||||
|
|
||||||
|
Rules that come out of the EULA and xbps:
|
||||||
|
|
||||||
|
- The NVIDIA libraries live in a private directory that is not on the loader path, so only
|
||||||
|
Voculinux's backend loads them ("accessed only by your application"). `libggml-cuda.so`
|
||||||
|
finds them through its rpath: `$ORIGIN` for `libggml-base`, then `/usr/lib/voculinux/cuda`.
|
||||||
|
- `voculinux-cuda-libs` sets `noshlibprovides=yes`, so no other Void package can link
|
||||||
|
against its copies, and keeps the files byte-identical to NVIDIA's archive (no strip:
|
||||||
|
`nostrip=yes`).
|
||||||
|
- Both new build templates set `restricted=yes`: Void's builders must never build or
|
||||||
|
publish them. The release container sets `XBPS_ALLOW_RESTRICTED=yes` for these two only.
|
||||||
|
- `python3-pywhispercpp-cuda` must be built from the identical pywhispercpp sdist,
|
||||||
|
version and revision as `python3-pywhispercpp`. A ggml backend built from a different
|
||||||
|
ggml source is refused at load time or, worse, crashes. The exact `depends` pin and a
|
||||||
|
test enforce this.
|
||||||
|
- `libcuda.so.1` and the CUDA libraries are not in Void's `common/shlibs`, so the CUDA
|
||||||
|
template lists them in `skiprdeps`, or sets `noverifyrdeps=yes`, and declares the real
|
||||||
|
dependencies in `depends`. Phase 1 decides which xbps-src accepts.
|
||||||
|
|
||||||
|
## 4. Phases
|
||||||
|
|
||||||
|
### Phase 0: preflight (maintainer, no code) — done 2026-11-07
|
||||||
|
|
||||||
|
Result: Gitea 1.27.1 on xServer accepted a 419,430,400-byte release asset (HTTP 201) in a
|
||||||
|
private scratch repository, and the authenticated download matched its sha256. The
|
||||||
|
scratch repository was deleted. No server limit change is needed, and the Coolify service
|
||||||
|
`gitea` carries no upload-limit overrides. The owner accepted the EULA terms (section 9).
|
||||||
|
|
||||||
|
Original steps, kept for re-checking after a Gitea upgrade:
|
||||||
|
|
||||||
|
1. Check the release upload limit on git.bongbetic.com: upload a 400 MiB file to a draft
|
||||||
|
release in a scratch repository with `tea`. If refused, raise
|
||||||
|
`[repository.release] FILE_MAX_SIZE` in the Gitea server config (infra change, needs
|
||||||
|
the owner's OK). Delete the draft release afterwards.
|
||||||
|
2. The owner accepts redistributing the NVIDIA libraries under the CUDA EULA (section 9).
|
||||||
|
|
||||||
|
Acceptance: a 400 MiB asset uploads and downloads anonymously from a public release.
|
||||||
|
|
||||||
|
### Phase 1: spike, measured before any template is committed
|
||||||
|
|
||||||
|
Work in a throwaway worktree, never on `main`.
|
||||||
|
|
||||||
|
1. In the pinned build image (`packaging/void/tool_checksums.txt`, `base-image`), unpack
|
||||||
|
the redistributable archives from section 5, build pywhispercpp 1.5.0 with:
|
||||||
|
`GGML_CUDA=1`, `-DGGML_BACKEND_DL=ON -DBUILD_SHARED_LIBS=ON -DGGML_NATIVE=OFF`,
|
||||||
|
`-DCMAKE_CUDA_ARCHITECTURES="75-real;86-real;89-real;120-real;120-virtual"`
|
||||||
|
(consumer GPUs only, section 9)
|
||||||
|
and fatbin compression, if ggml's CMake offers it for this nvcc
|
||||||
|
(`GGML_CUDA_COMPRESSION_MODE=size`; confirm the option exists in the vendored ggml).
|
||||||
|
2. Record: build time, `libggml-cuda.so` size (raw and zstd), and whether nvcc accepts
|
||||||
|
the image's GCC.
|
||||||
|
3. On the RTX 2050, with the packaged `python3-pywhispercpp` installed, copy the built
|
||||||
|
`libggml-cuda.so` beside the extension, put the three runtime libraries in a private
|
||||||
|
directory reached through rpath, and check:
|
||||||
|
- the extension registers the CUDA device (`voculinux --debug` shows `CUDA0`);
|
||||||
|
- with both `libggml-cuda.so` and `libggml-vulkan.so` present, which device whisper.cpp
|
||||||
|
takes as GPU device 0 (registration order in `ggml_backend_load_all_from_path`);
|
||||||
|
- `CUDA_VISIBLE_DEVICES=` (empty) set before the first import makes it use Vulkan;
|
||||||
|
- with `nvidia-libs` absent (any non-NVIDIA box or a container), loading the CUDA
|
||||||
|
backend fails quietly and Vulkan or CPU still works.
|
||||||
|
4. Measure the real-time factor with `scripts/measure_perf_baseline.py`, CUDA against
|
||||||
|
Vulkan, for `large-v3-turbo-q8_0`, `q5_0` and `small`.
|
||||||
|
|
||||||
|
Acceptance: a short report in the ticket with the numbers from steps 2 to 4. If the
|
||||||
|
backend does not load beside the packaged extension, stop and revise this plan before
|
||||||
|
phase 2.
|
||||||
|
|
||||||
|
### Phase 2: `voculinux-cuda-libs` template
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- `packaging/void/srcpkgs/voculinux-cuda-libs/template` (new): `distfiles` = the
|
||||||
|
`cuda_cudart` and `libcublas` archives, `checksum` from section 5, `do_install` copies
|
||||||
|
only `lib/libcudart.so.13*`, `lib/libcublas.so.13*`, `lib/libcublasLt.so.13*` to
|
||||||
|
`/usr/lib/voculinux/cuda/` (symlinks kept), `vlicense` the archive's `LICENSE`;
|
||||||
|
`restricted=yes`, `nostrip=yes`, `noshlibprovides=yes`, `archs="x86_64"`,
|
||||||
|
`license="custom:NVIDIA-CUDA-EULA"`.
|
||||||
|
- `tests/test_void_cuda_libs_template.py` (new): checksums equal the pinned manifest
|
||||||
|
values, only the three libraries are installed, the install path is private, the flags
|
||||||
|
above are set.
|
||||||
|
|
||||||
|
Acceptance: `xbps-src pkg voculinux-cuda-libs` in the release container; a test that the
|
||||||
|
installed files hash equal to the files inside the NVIDIA archive (unmodified).
|
||||||
|
|
||||||
|
### Phase 3: `python3-pywhispercpp-cuda` template
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- `packaging/void/srcpkgs/python3-pywhispercpp-cuda/template` (new). Same `version`,
|
||||||
|
`distfiles` (the pywhispercpp sdist) and `checksum` as `python3-pywhispercpp`, plus the
|
||||||
|
build-only CUDA archives (`cuda_nvcc`, `cuda_crt`, `libnvvm`, `cuda_cudart`,
|
||||||
|
`cuda_cccl`, `libcublas`). `pre_build` assembles a toolkit root under `$wrksrc/cuda`
|
||||||
|
from those archives and sets `CUDACXX`, `CUDAToolkit_ROOT`, the `CMAKE_ARGS` from phase
|
||||||
|
1 and `-DCMAKE_INSTALL_RPATH='$ORIGIN:/usr/lib/voculinux/cuda'`. Same
|
||||||
|
`patches/load-ggml-backends-beside-module.patch` (symlink or copy; the test checks they
|
||||||
|
are identical). `do_install` installs only `libggml-cuda.so` into `${py3_sitelib}`.
|
||||||
|
`depends="python3-pywhispercpp-${version}_${revision} voculinux-cuda-libs nvidia-libs"`.
|
||||||
|
`restricted=yes`, `archs="x86_64"`.
|
||||||
|
- `tests/test_void_pywhispercpp_template.py`: extend to assert that the CUDA template's
|
||||||
|
version, revision, sdist checksum and patch match `python3-pywhispercpp` exactly.
|
||||||
|
- `tests/test_void_pywhispercpp_cuda_template.py` (new): architecture list, rpath, only
|
||||||
|
one installed file, pinned CUDA checksums, exact-version dependency.
|
||||||
|
|
||||||
|
Acceptance: the package builds in the release container, contains exactly one file, and
|
||||||
|
`readelf -d` shows the rpath and `NEEDED` entries for `libcudart.so.13`,
|
||||||
|
`libcublas.so.13`, `libcuda.so.1` and `libggml-base.so.0`.
|
||||||
|
|
||||||
|
### Phase 4: `voculinux-cuda` meta package and install message
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- `packaging/void/srcpkgs/voculinux-cuda/template` (new): `build_style=meta`,
|
||||||
|
`depends="voculinux>=${version}_1 python3-pywhispercpp-cuda"`, its `version` kept equal
|
||||||
|
to `voculinux`'s.
|
||||||
|
- `packaging/void/srcpkgs/voculinux-cuda/INSTALL.msg` (new): needs the nonfree repository
|
||||||
|
(`xbps-install -S void-repo-nonfree`) and an NVIDIA driver that supports CUDA 13.2;
|
||||||
|
GPUs below compute capability 7.5 keep using Vulkan; how to check the backend
|
||||||
|
(`voculinux --debug`).
|
||||||
|
- `tests/test_void_app_template.py`: version of `voculinux-cuda` equals `voculinux`'s.
|
||||||
|
- `packaging/void/build-release.sh` `check-tag`: refuse a tag where the two differ.
|
||||||
|
|
||||||
|
Acceptance: `xbps-install -S voculinux-cuda` from a local staged repository pulls all
|
||||||
|
four packages.
|
||||||
|
|
||||||
|
### Phase 5: application backend selection
|
||||||
|
|
||||||
|
Today `detect_pywhispercpp_gpu_backend()`
|
||||||
|
(`src/vocalinux/speech_recognition/recognition_manager.py`) returns the first match of
|
||||||
|
`libggml-vulkan*` then `libggml-cuda*`, so with both installed it says `vulkan`. And
|
||||||
|
`cuda_toolkit_ready()` (`src/vocalinux/speech_recognition/model_ladder.py`) requires
|
||||||
|
`nvcc` or a complete toolkit root, which a packaged install never has.
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
|
||||||
|
1. `detect_pywhispercpp_gpu_backends() -> frozenset[str]`: every GPU backend library
|
||||||
|
present beside the extension (`{"cuda", "vulkan"}`, `{"vulkan"}`, or empty). Keep
|
||||||
|
`detect_pywhispercpp_gpu_backend()` as a thin wrapper that returns the selected one,
|
||||||
|
for current callers.
|
||||||
|
2. `cuda_backend_ready(installed: frozenset[str]) -> bool` in `model_ladder.py`, pure
|
||||||
|
over its probe inputs: `"cuda"` is installed, `nvidia-smi` answers, compute capability
|
||||||
|
at least `MIN_CUDA_COMPUTE_CAPABILITY`, and the CUDA runtime is reachable, meaning
|
||||||
|
`/usr/lib/voculinux/cuda/libcudart.so.13` exists or a complete toolkit root exists
|
||||||
|
(the current probe, kept for source builds).
|
||||||
|
3. `MIN_CUDA_COMPUTE_CAPABILITY` becomes `(7, 5)`: CUDA 13 does not build for older
|
||||||
|
GPUs, so a 7.0 card must not be offered CUDA.
|
||||||
|
4. Selection, one function, `select_gpu_backend(installed, cuda_ready, preference)`:
|
||||||
|
preference `auto` gives CUDA when ready, else Vulkan when installed, else CPU;
|
||||||
|
`vulkan` and `cpu` force those; `cuda` falls back with a logged warning when not ready.
|
||||||
|
5. Enforcement: when the selection is not CUDA, set `CUDA_VISIBLE_DEVICES=""` in
|
||||||
|
`os.environ` before the first `pywhispercpp` import, so ggml registers no CUDA device
|
||||||
|
and GPU device 0 is the Vulkan one. When it is CUDA and both are present, rely on the
|
||||||
|
registration order measured in phase 1; if Vulkan registers first, also set
|
||||||
|
`GGML_VK_VISIBLE_DEVICES` to hide the NVIDIA Vulkan device. The import happens in
|
||||||
|
`src/vocalinux/utils/pywhispercpp_loader.py`; set the variables there, once.
|
||||||
|
6. Config key `whispercpp_gpu_backend` (`auto` default; `cuda`, `vulkan`, `cpu`) in
|
||||||
|
`src/vocalinux/ui/config_manager.py`. A Settings control is a follow-up, not part of
|
||||||
|
this plan.
|
||||||
|
7. The model ladder already takes the backend name; pass the selected one, so CUDA uses
|
||||||
|
its own thresholds (1690 / 1404 MiB).
|
||||||
|
8. Log once at load: installed backends, selected backend, reason, device name.
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
|
||||||
|
- `tests/test_model_ladder.py`: `cuda_backend_ready` truth table (no driver, low
|
||||||
|
capability, packaged runtime only, toolkit only, neither); 7.0 now refused, 7.5 allowed.
|
||||||
|
- `tests/test_recognition_manager.py` (or a new `tests/test_gpu_backend_selection.py`):
|
||||||
|
detection with `cuda` only, `vulkan` only, both, none; selection for each preference;
|
||||||
|
the environment variables are set before import and only when needed.
|
||||||
|
- `tests/test_host_process.py` keeps passing: `nvidia-smi` probes run with
|
||||||
|
`env=host_env()`.
|
||||||
|
|
||||||
|
Acceptance: unit tests pass; on the RTX 2050 with the CUDA package installed, `auto`
|
||||||
|
picks CUDA, `vulkan` picks Vulkan, and removing `python3-pywhispercpp-cuda` returns to
|
||||||
|
Vulkan with no error.
|
||||||
|
|
||||||
|
### Phase 6: release pipeline
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- `packaging/void/smoke.sh`: add the three new packages to `PKGS`, in dependency order
|
||||||
|
(`voculinux-cuda-libs python3-pywhispercpp-cuda voculinux-cuda` after
|
||||||
|
`python3-pywhispercpp` and `voculinux`).
|
||||||
|
- `packaging/void/container-release.sh`: `XBPS_ALLOW_RESTRICTED=yes` in the build
|
||||||
|
container's `etc/conf`; payload checks for the new packages (one file in
|
||||||
|
`python3-pywhispercpp-cuda`, three libraries plus license in `voculinux-cuda-libs`);
|
||||||
|
install smoke: install `voculinux-cuda` from the staged signed repository and check
|
||||||
|
that, with no GPU and no driver in the container, `voculinux` still imports and selects
|
||||||
|
CPU without error. The container has no GPU, so CUDA itself is verified by hand
|
||||||
|
(phase 7).
|
||||||
|
- `packaging/void/build-release.sh`: nothing beyond `check-tag` (phase 4), unless phase 1
|
||||||
|
shows the build needs more time or memory limits.
|
||||||
|
- `packaging/void/publish-release.sh`: no change in calls; add a size preflight that
|
||||||
|
fails before any upload when an asset exceeds the limit found in phase 0.
|
||||||
|
- `packaging/void/tool_checksums.txt`: one `version` row naming the CUDA redist manifest
|
||||||
|
(`redistrib_13.2.0.json`) the archive checksums come from.
|
||||||
|
- Tests: `tests/test_void_smoke_gate.py`, `tests/test_void_release_build.py`,
|
||||||
|
`tests/test_void_publish_release.py` for the above.
|
||||||
|
|
||||||
|
Acceptance: `just void-release vX.Y.Z` builds and signs all eight packages; the dry run of
|
||||||
|
`bash packaging/void/publish-release.sh` lists every asset and passes the size preflight.
|
||||||
|
|
||||||
|
### Phase 7: documentation and hardware acceptance
|
||||||
|
|
||||||
|
Docs:
|
||||||
|
|
||||||
|
- `README.md`, section "GPU and CUDA": add the `voculinux-cuda` row and the install line.
|
||||||
|
- `packaging/void/README.md`: the CUDA packages, the private library directory, the EULA
|
||||||
|
rules, how to bump the CUDA version (new manifest, new checksums, rebuild both).
|
||||||
|
- `docs/RELEASE_PROCESS.md`: build time and size expectations; restricted packages.
|
||||||
|
- `docs/TROUBLESHOOTING.md`: "CUDA not used" checklist (nonfree repo, driver, capability,
|
||||||
|
`whispercpp_gpu_backend`, debug log line).
|
||||||
|
- `docs/UPDATE.md`, `CHANGELOG.md`: 0.20.0 entry.
|
||||||
|
|
||||||
|
Hardware acceptance on the RTX 2050, from the published repository:
|
||||||
|
|
||||||
|
1. `sudo xbps-install -Su voculinux-cuda` installs from git.bongbetic.com with the
|
||||||
|
pinned key and no prompt.
|
||||||
|
2. `voculinux --debug` logs the selected backend `cuda` and device `CUDA0`; dictation
|
||||||
|
types text.
|
||||||
|
3. Real-time factor and VRAM recorded with `scripts/measure_perf_baseline.py` and
|
||||||
|
compared with the Vulkan numbers in section 2.
|
||||||
|
4. `sudo xbps-remove voculinux-cuda python3-pywhispercpp-cuda voculinux-cuda-libs`, then
|
||||||
|
relaunch: Vulkan, no error.
|
||||||
|
5. On the AMD iGPU only (`CUDA_VISIBLE_DEVICES=` and the NVIDIA Vulkan device hidden),
|
||||||
|
Vulkan still works with the CUDA package installed.
|
||||||
|
|
||||||
|
## 5. Pinned CUDA inputs
|
||||||
|
|
||||||
|
From NVIDIA's `redistrib_13.2.0.json` (release date 2026-03-09), the version of the toolkit
|
||||||
|
already proven on the maintainer host. Base URL:
|
||||||
|
`https://developer.download.nvidia.com/compute/cuda/redist/`.
|
||||||
|
|
||||||
|
| Component | Version | sha256 | Used for |
|
||||||
|
|-----------|---------|--------|----------|
|
||||||
|
| `cuda_nvcc` | 13.2.51 | `706b996fefc59dc8d64d317fdf48d0aa84c4ae004eff43009dd918f40c5cc66a` | build only |
|
||||||
|
| `cuda_crt` | 13.2.51 | `fbc31fed55b7255591f3a19f575ca078827f5e6757d317d009f7ec1e69fcde4b` | build only |
|
||||||
|
| `libnvvm` | 13.2.51 | `e013fce38130d2337ea695aadc5ddd5dcfb78f9107903d72492b9819539749bb` | build only |
|
||||||
|
| `cuda_cccl` | 13.2.27 | `56e1bafb29faa87375b0484814870046530b88c0a421909096892f027ec1927b` | build only (headers) |
|
||||||
|
| `cuda_cudart` | 13.2.51 | `539edc1056e44d319f2112e9971c6415d78d4dde04b3f6ffbd20ec808e718526` | build and shipped (`libcudart.so.13`) |
|
||||||
|
| `libcublas` | 13.3.0.5 | `9f3c23d55b098d9159c435db59f228df79493152a778d4d367e7d60470d8c64b` | build and shipped (`libcublas.so.13`, `libcublasLt.so.13`) |
|
||||||
|
|
||||||
|
Each archive path is `<component>/linux-x86_64/<component>-linux-x86_64-<version>-archive.tar.xz`.
|
||||||
|
The `libcublas` archive is about 765 MiB to download, most of it static libraries that are
|
||||||
|
never shipped. xbps-src caches distfiles, so the two templates fetch it once per host.
|
||||||
|
|
||||||
|
Bumping CUDA: take the new `redistrib_<version>.json`, replace the six rows here and in
|
||||||
|
both templates in one pull request, rebuild both packages together, and re-run phase 7.
|
||||||
|
|
||||||
|
## 6. Size and time budget
|
||||||
|
|
||||||
|
| Item | Estimate | Source |
|
||||||
|
|------|----------|--------|
|
||||||
|
| `voculinux-cuda-libs` package | about 365 MiB compressed, 535 MiB installed | zstd of the 13.2 libraries |
|
||||||
|
| `python3-pywhispercpp-cuda` package | 36 MiB per architecture before fatbin compression; total measured in phase 1 | local sm_86 build |
|
||||||
|
| Build time of the CUDA backend | measured in phase 1 | four architectures plus PTX with nvcc |
|
||||||
|
| User download for `voculinux-cuda` | the two packages above | |
|
||||||
|
|
||||||
|
Architecture set (consumer GPUs only, owner decision):
|
||||||
|
|
||||||
|
| Target | GPUs |
|
||||||
|
|--------|------|
|
||||||
|
| `75-real` | Turing: GeForce GTX 16 series, RTX 20 series |
|
||||||
|
| `86-real` | Ampere consumer: RTX 30 series and RTX 2050 |
|
||||||
|
| `89-real` | Ada: RTX 40 series |
|
||||||
|
| `120-real` | Blackwell consumer: RTX 50 series |
|
||||||
|
| `120-virtual` | PTX, so a future consumer architecture JIT-compiles on first load |
|
||||||
|
|
||||||
|
Left out on purpose: `80` (A100), `87` (Jetson Orin), `90` (H100), `100` and `103`
|
||||||
|
(data-center Blackwell). Those GPUs are not Void desktop targets. An Ampere data-center
|
||||||
|
card would JIT from no matching PTX and fail to load the backend, so the app falls back
|
||||||
|
to Vulkan there.
|
||||||
|
|
||||||
|
## 7. Risks
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|------------|
|
||||||
|
| Release asset upload limit below the package size | closed: phase 0 uploaded 400 MiB; `publish-release.sh` still gets a size preflight (phase 6) |
|
||||||
|
| ggml backend ABI mismatch between the two pywhispercpp builds | exact `version_revision` dependency plus a template-equality test |
|
||||||
|
| nvcc 13.2 rejects the image's GCC | phase 1 finds out; if so, use a GCC the image provides and nvcc supports, never `-allow-unsupported-compiler` |
|
||||||
|
| Driver too old for CUDA 13.2 | `INSTALL.msg` states the minimum driver from NVIDIA's CUDA 13.2 release notes; selection falls back when `cudaGetDeviceCount` fails |
|
||||||
|
| Vulkan and CUDA both register the same NVIDIA GPU | phase 1 measures order; phase 5 hides the unused one with environment variables |
|
||||||
|
| No speed gain on small GPUs | owner chose CUDA first regardless; the `vulkan` preference is one config key away; phase 7 records numbers |
|
||||||
|
| Void's `nvidia-libs` upgrade changes the driver ABI | `libcuda.so.1` is the stable driver ABI; phase 7 re-run after major driver updates |
|
||||||
|
|
||||||
|
## 8. Ticket breakdown
|
||||||
|
|
||||||
|
Dependencies in brackets.
|
||||||
|
|
||||||
|
| # | Ticket | Type |
|
||||||
|
|---|--------|------|
|
||||||
|
| 1 | Phase 0: release upload limit and EULA decision (done 2026-11-07) | human / infra |
|
||||||
|
| 2 | Phase 1: CUDA backend spike and measurements [1] | spike |
|
||||||
|
| 3 | Phase 2: `voculinux-cuda-libs` template [2] | AFK |
|
||||||
|
| 4 | Phase 3: `python3-pywhispercpp-cuda` template [2, 3] | AFK |
|
||||||
|
| 5 | Phase 4: `voculinux-cuda` meta package [4] | AFK |
|
||||||
|
| 6 | Phase 5: backend detection, selection and config key [2] | AFK, parallel with 3 to 5 |
|
||||||
|
| 7 | Phase 6: release pipeline [3, 4, 5] | AFK |
|
||||||
|
| 8 | Phase 7: docs, 0.20.0 release and hardware acceptance [6, 7] | human-in-the-loop |
|
||||||
|
|
||||||
|
## 9. Owner decisions (taken 2026-11-07)
|
||||||
|
|
||||||
|
1. **EULA: accepted.** `libcudart`, `libcublas` and `libcublasLt` are redistributed from
|
||||||
|
this repository under the NVIDIA CUDA EULA, unmodified, with the license shipped.
|
||||||
|
2. **Gitea upload limit: may be raised.** Phase 0 showed no change is needed.
|
||||||
|
3. **Default preference: CUDA first.** `auto` selects CUDA whenever it is ready, then
|
||||||
|
Vulkan, then CPU, even though Vulkan matched CUDA on the RTX 2050.
|
||||||
|
4. **Architectures: consumer GPUs only.** `75-real;86-real;89-real;120-real;120-virtual`
|
||||||
|
(section 6).
|
||||||
@@ -198,7 +198,7 @@ just void-smoke # the gate: build, sign with a throwaway k
|
|||||||
just void-release v0.19.0 # build the release set, with XBPS_SIGNING_KEY_FILE set
|
just void-release v0.19.0 # build the release set, with XBPS_SIGNING_KEY_FILE set
|
||||||
packaging/void/build-release.sh names v0.19.0-beta # offline: the names a tag maps to
|
packaging/void/build-release.sh names v0.19.0-beta # offline: the names a tag maps to
|
||||||
packaging/void/build-release.sh check-tag v0.19.0-beta # offline: the tag gate
|
packaging/void/build-release.sh check-tag v0.19.0-beta # offline: the tag gate
|
||||||
packaging/void/publish-release.sh v0.19.0 --out-dir DIR --dry-run # every check, the planned calls, no request
|
bash packaging/void/publish-release.sh v0.19.0 --out-dir DIR --dry-run # every check, the planned calls, no request
|
||||||
```
|
```
|
||||||
|
|
||||||
Podman only, never Docker. The base image digest and the void-packages commit
|
Podman only, never Docker. The base image digest and the void-packages commit
|
||||||
@@ -315,11 +315,11 @@ cannot build this release. The sequence is:
|
|||||||
then, in that worktree,
|
then, in that worktree,
|
||||||
`packaging/void/build-release.sh release vX.Y.Z --key-file /dev/shm/xbps-sign-key.pem --out-dir /path/to/out`.
|
`packaging/void/build-release.sh release vX.Y.Z --key-file /dev/shm/xbps-sign-key.pem --out-dir /path/to/out`.
|
||||||
Remove the key file when the run ends.
|
Remove the key file when the run ends.
|
||||||
4. **Dry-run the publish:** `packaging/void/publish-release.sh vX.Y.Z --out-dir /path/to/out --dry-run`.
|
4. **Dry-run the publish:** `bash packaging/void/publish-release.sh vX.Y.Z --out-dir /path/to/out --dry-run`.
|
||||||
It checks that origin's `vX.Y.Z` peels to the commit in `BUILT_FROM`, that the sdist
|
It checks that origin's `vX.Y.Z` peels to the commit in `BUILT_FROM`, that the sdist
|
||||||
matches `SDIST_SHA256`, and that the client key is the committed one, then prints the
|
matches `SDIST_SHA256`, and that the client key is the committed one, then prints the
|
||||||
calls it would make. It sends no request and reads no token.
|
calls it would make. It sends no request and reads no token.
|
||||||
5. **Publish:** `packaging/void/publish-release.sh vX.Y.Z --out-dir /path/to/out`. The tag's
|
5. **Publish:** `bash packaging/void/publish-release.sh vX.Y.Z --out-dir /path/to/out`. The tag's
|
||||||
release gets the sdist, packages, signatures, `FINGERPRINT` and the client `.plist`, and
|
release gets the sdist, packages, signatures, `FINGERPRINT` and the client `.plist`, and
|
||||||
the rolling `latest` release that the client registration URL serves is replaced, with the
|
the rolling `latest` release that the client registration URL serves is replaced, with the
|
||||||
repodata last. The token comes from `GITEA_TOKEN`, or from the `tea` login for
|
repodata last. The token comes from `GITEA_TOKEN`, or from the `tea` login for
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
# Template file for 'voculinux'
|
# Template file for 'voculinux'
|
||||||
pkgname=voculinux
|
pkgname=voculinux
|
||||||
version=0.19.0
|
version=0.19.1
|
||||||
revision=1
|
revision=1
|
||||||
# The release tag this template names: v + __version__, with the pre-release
|
# The release tag this template names: v + __version__, with the pre-release
|
||||||
# hyphen kept (the tag is v0.19.0-beta while version is 0.19.0beta). The release
|
# hyphen kept (the tag is v0.19.0-beta while version is 0.19.0beta). The release
|
||||||
# gate (packaging/void/build-release.sh check-tag) refuses any mismatch.
|
# gate (packaging/void/build-release.sh check-tag) refuses any mismatch.
|
||||||
_tag=v0.19.0
|
_tag=v0.19.1
|
||||||
# The release sdist ships no setup.py (setuptools pyproject-only), so the
|
# The release sdist ships no setup.py (setuptools pyproject-only), so the
|
||||||
# build goes through the PEP 517 wheel (cf. Solaar, PhotoCollage).
|
# build goes through the PEP 517 wheel (cf. Solaar, PhotoCollage).
|
||||||
build_style=python3-pep517
|
build_style=python3-pep517
|
||||||
|
|||||||
@@ -14,13 +14,22 @@ the payload files above) is rewritten in the copied configuration to that file's
|
|||||||
new location, whether or not this run copied it. Edits then never land in the
|
new location, whether or not this run copied it. Edits then never land in the
|
||||||
legacy file. Any other path, including a user-chosen file elsewhere or another
|
legacy file. Any other path, including a user-chosen file elsewhere or another
|
||||||
file inside the legacy directory, is kept as stored.
|
file inside the legacy directory, is kept as stored.
|
||||||
|
|
||||||
|
Ticket #52: a previous install can also leave user-level launcher files behind
|
||||||
|
(a desktop entry and two wrapper scripts). When their target is gone they start
|
||||||
|
nothing yet still show up as a second app. :func:`report_stale_upstream_launchers`
|
||||||
|
logs and shows one notice naming them, then records a marker. It never removes
|
||||||
|
or changes a file. It runs from the GTK idle loop after the tray is up and does
|
||||||
|
only stat calls and small reads.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
from typing import Optional
|
from typing import Callable, Optional
|
||||||
|
|
||||||
from .app_identity import APP_DIR_NAME, LEGACY_APP_DIR_NAME
|
from .app_identity import APP_DIR_NAME, LEGACY_APP_DIR_NAME
|
||||||
|
|
||||||
@@ -31,6 +40,17 @@ TERMS_FILENAME: str = "dictionary.txt"
|
|||||||
CORRECTIONS_FILENAME: str = "custom-dictionary-corrections.json"
|
CORRECTIONS_FILENAME: str = "custom-dictionary-corrections.json"
|
||||||
RESTORE_TOKEN_FILENAME: str = "remote_desktop_restore_token"
|
RESTORE_TOKEN_FILENAME: str = "remote_desktop_restore_token"
|
||||||
|
|
||||||
|
#: Leftover upstream launcher files, reported and never removed (ticket #52).
|
||||||
|
LEGACY_DESKTOP_FILENAME: str = f"{LEGACY_APP_DIR_NAME}.desktop"
|
||||||
|
LEGACY_LAUNCHER_NAMES: tuple[str, ...] = (LEGACY_APP_DIR_NAME, f"{LEGACY_APP_DIR_NAME}-gui")
|
||||||
|
#: Written in the new data directory once the stale-launcher notice has been shown.
|
||||||
|
STALE_LAUNCHER_MARKER: str = "stale-upstream-launcher-notified"
|
||||||
|
#: Upper bound on what is read from a launcher. Wrappers are a few KiB.
|
||||||
|
_MAX_SCRIPT_BYTES: int = 64 * 1024
|
||||||
|
_ENV_ASSIGNMENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*=")
|
||||||
|
_ABSOLUTE_PATH = re.compile(r"/[A-Za-z0-9._@+%~-]+(?:/[A-Za-z0-9._@+%~-]+)+")
|
||||||
|
_INTERPRETER_NAME = re.compile(r"python[\d.]*")
|
||||||
|
|
||||||
#: Files copied from the legacy configuration directory. Models are
|
#: Files copied from the legacy configuration directory. Models are
|
||||||
#: deliberately absent: they re-resolve through the pinned-checksum download
|
#: deliberately absent: they re-resolve through the pinned-checksum download
|
||||||
#: path instead of being migrated.
|
#: path instead of being migrated.
|
||||||
@@ -170,3 +190,159 @@ def import_legacy_once() -> list[str]:
|
|||||||
if imported:
|
if imported:
|
||||||
logger.info("Legacy Vocalinux import complete: %d file(s)", len(imported))
|
logger.info("Legacy Vocalinux import complete: %d file(s)", len(imported))
|
||||||
return imported
|
return imported
|
||||||
|
|
||||||
|
|
||||||
|
def _read_head(path: str) -> Optional[str]:
|
||||||
|
"""Return the first ``_MAX_SCRIPT_BYTES`` of a text file, or None when unreadable."""
|
||||||
|
try:
|
||||||
|
with open(path, encoding="utf-8", errors="replace") as handle:
|
||||||
|
return handle.read(_MAX_SCRIPT_BYTES)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_interpreter_or_entry(path: str) -> bool:
|
||||||
|
"""True for a path to an interpreter or a legacy entry script inside a bin directory."""
|
||||||
|
name = os.path.basename(path)
|
||||||
|
if os.path.basename(os.path.dirname(path)) != "bin":
|
||||||
|
return False
|
||||||
|
return _INTERPRETER_NAME.fullmatch(name) is not None or name in LEGACY_LAUNCHER_NAMES
|
||||||
|
|
||||||
|
|
||||||
|
def _names_missing_interpreter(path: str) -> bool:
|
||||||
|
"""True when ``path`` is a script whose shebang or interpreter or venv entry is gone.
|
||||||
|
|
||||||
|
Only a regular file that starts with ``#!`` counts as a script, so a compiled
|
||||||
|
binary that happens to contain a dead path is never flagged.
|
||||||
|
"""
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
return False
|
||||||
|
text = _read_head(path)
|
||||||
|
if text is None or not text.startswith("#!"):
|
||||||
|
return False
|
||||||
|
shebang = text.split("\n", 1)[0][2:].split()
|
||||||
|
if shebang and shebang[0].startswith("/") and not os.path.exists(shebang[0]):
|
||||||
|
return True
|
||||||
|
return any(
|
||||||
|
_is_interpreter_or_entry(token) and not os.path.exists(token)
|
||||||
|
for token in _ABSOLUTE_PATH.findall(text)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _launcher_is_stale(path: str) -> bool:
|
||||||
|
"""True when a launcher file is missing (a dangling link) or names a missing interpreter."""
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return True
|
||||||
|
return _names_missing_interpreter(path)
|
||||||
|
|
||||||
|
|
||||||
|
def _desktop_exec(path: str) -> Optional[str]:
|
||||||
|
"""Return the ``Exec=`` value of the ``[Desktop Entry]`` group, or None."""
|
||||||
|
text = _read_head(path)
|
||||||
|
if text is None:
|
||||||
|
return None
|
||||||
|
in_entry = False
|
||||||
|
for raw in text.splitlines():
|
||||||
|
line = raw.strip()
|
||||||
|
if line.startswith("["):
|
||||||
|
in_entry = line == "[Desktop Entry]"
|
||||||
|
elif in_entry and line.startswith("Exec="):
|
||||||
|
return line[len("Exec=") :]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _exec_target(exec_value: str) -> Optional[str]:
|
||||||
|
"""Return the program an Exec value starts, after any ``env`` and ``VAR=`` prefix."""
|
||||||
|
try:
|
||||||
|
words = shlex.split(exec_value)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if words and os.path.basename(words[0]) == "env":
|
||||||
|
words = words[1:]
|
||||||
|
while words and _ENV_ASSIGNMENT.match(words[0]):
|
||||||
|
words = words[1:]
|
||||||
|
return words[0] if words else None
|
||||||
|
|
||||||
|
|
||||||
|
def _desktop_entry_is_stale(path: str) -> bool:
|
||||||
|
"""True when a desktop entry starts a target that is missing or stale."""
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return True
|
||||||
|
exec_value = _desktop_exec(path)
|
||||||
|
target = _exec_target(exec_value) if exec_value is not None else None
|
||||||
|
if target is None:
|
||||||
|
return False
|
||||||
|
if "/" in target or target.startswith("~"):
|
||||||
|
return _launcher_is_stale(os.path.expanduser(target))
|
||||||
|
# A bare name is looked up on PATH with stat calls only; no subprocess.
|
||||||
|
resolved = shutil.which(target)
|
||||||
|
return resolved is None or _launcher_is_stale(resolved)
|
||||||
|
|
||||||
|
|
||||||
|
def find_stale_upstream_launchers() -> list[str]:
|
||||||
|
"""Return the leftover upstream launcher files that no longer start Voculinux.
|
||||||
|
|
||||||
|
Checks three paths: the user-level desktop entry and the two wrappers in
|
||||||
|
``~/.local/bin``. Only positive evidence counts: a missing target, a dangling
|
||||||
|
link, or a script that names a missing interpreter or venv entry. A working
|
||||||
|
install is never reported.
|
||||||
|
"""
|
||||||
|
stale: list[str] = []
|
||||||
|
desktop = os.path.join(
|
||||||
|
_app_dir("XDG_DATA_HOME", "~/.local/share", "applications"), LEGACY_DESKTOP_FILENAME
|
||||||
|
)
|
||||||
|
if os.path.lexists(desktop) and _desktop_entry_is_stale(desktop):
|
||||||
|
stale.append(desktop)
|
||||||
|
bin_dir = os.path.expanduser("~/.local/bin")
|
||||||
|
for name in LEGACY_LAUNCHER_NAMES:
|
||||||
|
launcher = os.path.join(bin_dir, name)
|
||||||
|
if os.path.lexists(launcher) and _launcher_is_stale(launcher):
|
||||||
|
stale.append(launcher)
|
||||||
|
return stale
|
||||||
|
|
||||||
|
|
||||||
|
def _record_notice(marker: str) -> None:
|
||||||
|
"""Write the marker that says the stale-launcher notice has been shown."""
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(marker), exist_ok=True)
|
||||||
|
with open(marker, "w", encoding="utf-8") as handle:
|
||||||
|
handle.write("shown\n")
|
||||||
|
except OSError as error:
|
||||||
|
logger.warning("Could not record the stale launcher notice at %s: %s", marker, error)
|
||||||
|
|
||||||
|
|
||||||
|
def report_stale_upstream_launchers(
|
||||||
|
notifier: Optional[Callable[..., object]] = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Log and show, once, leftover upstream launchers that no longer start Voculinux.
|
||||||
|
|
||||||
|
Nothing is scanned once the marker exists. A clean run records nothing, so a
|
||||||
|
stale launcher that appears later is still reported. The marker is written
|
||||||
|
after the notice. No file is removed or modified.
|
||||||
|
|
||||||
|
``notifier`` takes the signature of :func:`vocalinux.ui.notifications.notify`
|
||||||
|
and defaults to it. Returns the stale paths reported this run.
|
||||||
|
"""
|
||||||
|
marker = os.path.join(new_data_dir(), STALE_LAUNCHER_MARKER)
|
||||||
|
if os.path.exists(marker):
|
||||||
|
return []
|
||||||
|
stale = find_stale_upstream_launchers()
|
||||||
|
if not stale:
|
||||||
|
return []
|
||||||
|
for path in stale:
|
||||||
|
logger.warning(
|
||||||
|
"Old Vocalinux launcher %s starts nothing: its install is gone; remove it yourself",
|
||||||
|
path,
|
||||||
|
)
|
||||||
|
if notifier is None:
|
||||||
|
from .ui import notifications
|
||||||
|
|
||||||
|
notifier = notifications.notify
|
||||||
|
notifier(
|
||||||
|
"Old Vocalinux launcher found",
|
||||||
|
"These files from a previous Vocalinux install point at an install that is gone "
|
||||||
|
"and start nothing. Remove them; Voculinux leaves them in place.\n" + "\n".join(stale),
|
||||||
|
icon="dialog-warning",
|
||||||
|
)
|
||||||
|
_record_notice(marker)
|
||||||
|
return stale
|
||||||
|
|||||||
@@ -344,6 +344,20 @@ def check_appindicator_support():
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _report_stale_upstream_launchers() -> bool:
|
||||||
|
"""GLib idle callback: show the one-time stale-launcher notice (#52), then stop.
|
||||||
|
|
||||||
|
Returns False so GLib drops the callback after one run.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from .legacy_import import report_stale_upstream_launchers
|
||||||
|
|
||||||
|
report_stale_upstream_launchers()
|
||||||
|
except Exception as error:
|
||||||
|
logger.debug("Stale launcher check skipped: %s", error)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""Main entry point for the application."""
|
"""Main entry point for the application."""
|
||||||
# Parse arguments first so flags like --version work even when
|
# Parse arguments first so flags like --version work even when
|
||||||
@@ -1388,6 +1402,8 @@ def main():
|
|||||||
# callback has put the icon up. The load runs on its own thread, so the
|
# callback has put the icon up. The load runs on its own thread, so the
|
||||||
# tray is usable while it loads (#50).
|
# tray is usable while it loads (#50).
|
||||||
GLib.idle_add(speech_engine.load_model_in_background)
|
GLib.idle_add(speech_engine.load_model_in_background)
|
||||||
|
# Leftover upstream launchers (#52): checked after the tray is up, once.
|
||||||
|
GLib.idle_add(_report_stale_upstream_launchers)
|
||||||
|
|
||||||
# Start the GTK main loop
|
# Start the GTK main loop
|
||||||
indicator.run()
|
indicator.run()
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
Version information for Voculinux.
|
Version information for Voculinux.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "0.19.0"
|
__version__ = "0.19.1"
|
||||||
__version_info__ = (0, 19, 0)
|
__version_info__ = (0, 19, 1)
|
||||||
__author__ = "Jatin K Malik"
|
__author__ = "Jatin K Malik"
|
||||||
__email__ = "jatinkrmalik@gmail.com"
|
__email__ = "jatinkrmalik@gmail.com"
|
||||||
__license__ = "AGPL-3.0-only"
|
__license__ = "AGPL-3.0-only"
|
||||||
|
|||||||
@@ -277,3 +277,253 @@ def test_existing_new_dictionary_takes_the_edit_and_legacy_stays_put(monkeypatch
|
|||||||
assert legacy_terms.read_bytes() == legacy_bytes
|
assert legacy_terms.read_bytes() == legacy_bytes
|
||||||
assert legacy_terms.stat().st_mtime_ns == legacy_mtime
|
assert legacy_terms.stat().st_mtime_ns == legacy_mtime
|
||||||
assert (new_config_dir / "dictionary.txt").read_text(encoding="utf-8") == "Mine\nKubernetes\n"
|
assert (new_config_dir / "dictionary.txt").read_text(encoding="utf-8") == "Mine\nKubernetes\n"
|
||||||
|
|
||||||
|
|
||||||
|
# --- #52: leftover upstream launchers -----------------------------------------
|
||||||
|
#
|
||||||
|
# A previous install can leave a user-level desktop entry and two wrapper
|
||||||
|
# scripts behind. When their venv is gone they start nothing, yet they show up
|
||||||
|
# as a second app in launchers. Every path here lives under a fake HOME, XDG
|
||||||
|
# roots and PATH, so the real home is never read.
|
||||||
|
|
||||||
|
_LEGACY_DESKTOP = "vocalinux.desktop" # legacy launcher file name
|
||||||
|
_LEGACY_LAUNCH = "vocalinux" # legacy CLI wrapper name
|
||||||
|
_LEGACY_GUI = "vocalinux-gui" # legacy GUI wrapper name
|
||||||
|
_LEGACY_TITLE = "Vocalinux" # legacy launcher display name
|
||||||
|
_GI_PREFIX = "env GI_TYPELIB_PATH=/usr/lib/girepository-1.0"
|
||||||
|
|
||||||
|
|
||||||
|
def _isolate_launchers(monkeypatch, tmp_path: Path) -> tuple[Path, Path, Path]:
|
||||||
|
"""Point HOME, XDG and PATH at scratch dirs; return (home, data_root, path_bin)."""
|
||||||
|
home = tmp_path / "home"
|
||||||
|
home.mkdir()
|
||||||
|
data_root = tmp_path / "data"
|
||||||
|
path_bin = tmp_path / "path-bin"
|
||||||
|
path_bin.mkdir()
|
||||||
|
monkeypatch.setenv("HOME", str(home))
|
||||||
|
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
|
||||||
|
monkeypatch.setenv("XDG_DATA_HOME", str(data_root))
|
||||||
|
monkeypatch.setenv("PATH", str(path_bin))
|
||||||
|
return home, data_root, path_bin
|
||||||
|
|
||||||
|
|
||||||
|
def _bin_dir(home: Path) -> Path:
|
||||||
|
return home / ".local" / "bin"
|
||||||
|
|
||||||
|
|
||||||
|
def _write_desktop(data_root: Path, exec_value: str, extra: str = "") -> Path:
|
||||||
|
"""Write a desktop entry under the fake XDG data home."""
|
||||||
|
entry = data_root / "applications" / _LEGACY_DESKTOP
|
||||||
|
entry.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
entry.write_text(
|
||||||
|
f"[Desktop Entry]\nType=Application\nName={_LEGACY_TITLE}\nExec={exec_value}\n{extra}",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def _make_venv(root: Path, entry: str) -> Path:
|
||||||
|
"""Create a live venv with an interpreter and the entry script."""
|
||||||
|
venv = root / "venv"
|
||||||
|
(venv / "bin").mkdir(parents=True)
|
||||||
|
for name in ("python", entry):
|
||||||
|
script = venv / "bin" / name
|
||||||
|
script.write_text("#!/bin/sh\n", encoding="utf-8")
|
||||||
|
script.chmod(0o755)
|
||||||
|
return venv
|
||||||
|
|
||||||
|
|
||||||
|
def _write_wrapper(path: Path, venv: Path, entry: str) -> Path:
|
||||||
|
"""Write a wrapper shaped like the installer's, bound to ``venv``."""
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(
|
||||||
|
"#!/bin/bash\n"
|
||||||
|
"export GI_TYPELIB_PATH=/usr/lib/girepository-1.0\n"
|
||||||
|
f'PY_SITE_PATHS=$("{venv / "bin" / "python"}" - <<\'PY\' 2>/dev/null\n'
|
||||||
|
"PY\n"
|
||||||
|
f'EXEC_CMD="{venv / "bin" / entry} \\$*"\n'
|
||||||
|
'exec sg input -c "$EXEC_CMD"\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
path.chmod(0o755)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _recorder(calls: list):
|
||||||
|
"""Return a notifier stand-in that records (title, message, icon)."""
|
||||||
|
|
||||||
|
def _notify(title: str, message: str, icon: str = "") -> None:
|
||||||
|
calls.append((title, message, icon))
|
||||||
|
|
||||||
|
return _notify
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_upstream_launchers_means_no_notice(monkeypatch, tmp_path) -> None:
|
||||||
|
"""A clean machine has nothing to report and writes no marker."""
|
||||||
|
_, data_root, _ = _isolate_launchers(monkeypatch, tmp_path)
|
||||||
|
calls: list = []
|
||||||
|
|
||||||
|
assert legacy_import.report_stale_upstream_launchers(notifier=_recorder(calls)) == []
|
||||||
|
|
||||||
|
assert calls == []
|
||||||
|
assert not (data_root / "voculinux" / legacy_import.STALE_LAUNCHER_MARKER).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_exec_target_is_flagged_named_and_shown_once(monkeypatch, tmp_path, caplog) -> None:
|
||||||
|
"""A desktop entry whose target is gone is logged, notified once, and left untouched."""
|
||||||
|
home, data_root, _ = _isolate_launchers(monkeypatch, tmp_path)
|
||||||
|
entry = _write_desktop(data_root, str(_bin_dir(home) / _LEGACY_GUI)) # never created
|
||||||
|
before = entry.read_bytes()
|
||||||
|
calls: list = []
|
||||||
|
|
||||||
|
with caplog.at_level("WARNING", logger="vocalinux.legacy_import"):
|
||||||
|
stale = legacy_import.report_stale_upstream_launchers(notifier=_recorder(calls))
|
||||||
|
|
||||||
|
assert stale == [str(entry)]
|
||||||
|
assert any(str(entry) in r.getMessage() for r in caplog.records if r.levelname == "WARNING")
|
||||||
|
assert len(calls) == 1
|
||||||
|
title, message, _icon = calls[0]
|
||||||
|
assert title == "Old Vocalinux launcher found"
|
||||||
|
assert str(entry) in message
|
||||||
|
assert entry.read_bytes() == before
|
||||||
|
|
||||||
|
again: list = []
|
||||||
|
assert legacy_import.report_stale_upstream_launchers(notifier=_recorder(again)) == []
|
||||||
|
assert again == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_prefixed_exec_into_wrapper_with_missing_venv_is_flagged(monkeypatch, tmp_path) -> None:
|
||||||
|
"""The reported case: a GI_TYPELIB_PATH env prefix, then a wrapper whose venv is gone."""
|
||||||
|
home, data_root, _ = _isolate_launchers(monkeypatch, tmp_path)
|
||||||
|
gone_venv = tmp_path / "gone" / "venv"
|
||||||
|
gui = _write_wrapper(_bin_dir(home) / _LEGACY_GUI, gone_venv, _LEGACY_GUI)
|
||||||
|
entry = _write_desktop(data_root, f"{_GI_PREFIX} {gui}")
|
||||||
|
|
||||||
|
assert legacy_import.find_stale_upstream_launchers() == [str(entry), str(gui)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_both_wrappers_are_flagged_when_their_venv_is_gone(monkeypatch, tmp_path) -> None:
|
||||||
|
"""Without a desktop entry, each stale wrapper is still named."""
|
||||||
|
home, _, _ = _isolate_launchers(monkeypatch, tmp_path)
|
||||||
|
gone_venv = tmp_path / "gone" / "venv"
|
||||||
|
launch = _write_wrapper(_bin_dir(home) / _LEGACY_LAUNCH, gone_venv, _LEGACY_LAUNCH)
|
||||||
|
gui = _write_wrapper(_bin_dir(home) / _LEGACY_GUI, gone_venv, _LEGACY_GUI)
|
||||||
|
|
||||||
|
assert legacy_import.find_stale_upstream_launchers() == [str(launch), str(gui)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_working_upstream_install_is_not_flagged(monkeypatch, tmp_path) -> None:
|
||||||
|
"""A launcher whose target and venv exist is the user's choice, never reported."""
|
||||||
|
home, data_root, _ = _isolate_launchers(monkeypatch, tmp_path)
|
||||||
|
venv = _make_venv(tmp_path / "live", _LEGACY_GUI)
|
||||||
|
gui = _write_wrapper(_bin_dir(home) / _LEGACY_GUI, venv, _LEGACY_GUI)
|
||||||
|
_write_desktop(data_root, f"{_GI_PREFIX} {gui}")
|
||||||
|
calls: list = []
|
||||||
|
|
||||||
|
assert legacy_import.report_stale_upstream_launchers(notifier=_recorder(calls)) == []
|
||||||
|
|
||||||
|
assert calls == []
|
||||||
|
assert not (data_root / "voculinux" / legacy_import.STALE_LAUNCHER_MARKER).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_dangling_wrapper_symlink_is_flagged(monkeypatch, tmp_path) -> None:
|
||||||
|
"""A symlink whose target was removed starts nothing, so it is stale."""
|
||||||
|
home, _, _ = _isolate_launchers(monkeypatch, tmp_path)
|
||||||
|
link = _bin_dir(home) / _LEGACY_LAUNCH
|
||||||
|
link.parent.mkdir(parents=True)
|
||||||
|
link.symlink_to(tmp_path / "removed" / "bin" / _LEGACY_LAUNCH)
|
||||||
|
|
||||||
|
assert legacy_import.find_stale_upstream_launchers() == [str(link)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_compiled_binary_is_not_read_as_a_wrapper(monkeypatch, tmp_path) -> None:
|
||||||
|
"""Only a script with a shebang is a wrapper; a binary that names a dead path is left alone."""
|
||||||
|
home, _, _ = _isolate_launchers(monkeypatch, tmp_path)
|
||||||
|
binary = _bin_dir(home) / _LEGACY_LAUNCH
|
||||||
|
binary.parent.mkdir(parents=True)
|
||||||
|
binary.write_bytes(b"\x7fELF\x02\x01\x01" + str(tmp_path / "gone" / "bin" / "python").encode())
|
||||||
|
|
||||||
|
assert legacy_import.find_stale_upstream_launchers() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_bare_exec_name_is_resolved_through_path(monkeypatch, tmp_path) -> None:
|
||||||
|
"""A bare Exec name is stale when PATH cannot find it, and fine when a working copy is there."""
|
||||||
|
_, data_root, path_bin = _isolate_launchers(monkeypatch, tmp_path)
|
||||||
|
entry = _write_desktop(data_root, _LEGACY_LAUNCH)
|
||||||
|
|
||||||
|
assert legacy_import.find_stale_upstream_launchers() == [str(entry)]
|
||||||
|
|
||||||
|
venv = _make_venv(tmp_path / "live", _LEGACY_LAUNCH)
|
||||||
|
_write_wrapper(path_bin / _LEGACY_LAUNCH, venv, _LEGACY_LAUNCH)
|
||||||
|
|
||||||
|
assert legacy_import.find_stale_upstream_launchers() == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("exec_value", ["", "env", '"unterminated /usr/bin/x'])
|
||||||
|
def test_exec_that_names_no_usable_target_is_not_flagged(monkeypatch, tmp_path, exec_value) -> None:
|
||||||
|
"""Empty, env-only and unparseable Exec lines give no evidence, so they are not flagged."""
|
||||||
|
_, data_root, _ = _isolate_launchers(monkeypatch, tmp_path)
|
||||||
|
_write_desktop(data_root, exec_value)
|
||||||
|
|
||||||
|
assert legacy_import.find_stale_upstream_launchers() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_the_main_entry_exec_counts(monkeypatch, tmp_path) -> None:
|
||||||
|
"""An Exec inside a Desktop Action group is not the launcher's target."""
|
||||||
|
_, data_root, _ = _isolate_launchers(monkeypatch, tmp_path)
|
||||||
|
live = tmp_path / "live-launcher"
|
||||||
|
live.write_text("#!/bin/sh\n", encoding="utf-8")
|
||||||
|
_write_desktop(
|
||||||
|
data_root,
|
||||||
|
str(live),
|
||||||
|
extra=f"\n[Desktop Action Old]\nName=Old\nExec={tmp_path / 'gone'}\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert legacy_import.find_stale_upstream_launchers() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_marker_skips_the_scan_and_the_notice(monkeypatch, tmp_path) -> None:
|
||||||
|
"""Once the notice has been recorded, nothing is re-scanned or shown again."""
|
||||||
|
home, data_root, _ = _isolate_launchers(monkeypatch, tmp_path)
|
||||||
|
_write_desktop(data_root, str(_bin_dir(home) / _LEGACY_GUI))
|
||||||
|
marker = data_root / "voculinux" / legacy_import.STALE_LAUNCHER_MARKER
|
||||||
|
marker.parent.mkdir(parents=True)
|
||||||
|
marker.write_text("shown\n", encoding="utf-8")
|
||||||
|
calls: list = []
|
||||||
|
|
||||||
|
assert legacy_import.report_stale_upstream_launchers(notifier=_recorder(calls)) == []
|
||||||
|
assert calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_xdg_data_home_falls_back_to_the_default(monkeypatch, tmp_path) -> None:
|
||||||
|
"""An empty XDG_DATA_HOME means ~/.local/share, as the rest of the module reads it."""
|
||||||
|
home, _, _ = _isolate_launchers(monkeypatch, tmp_path)
|
||||||
|
monkeypatch.setenv("XDG_DATA_HOME", "")
|
||||||
|
entry = _write_desktop(home / ".local" / "share", str(_bin_dir(home) / _LEGACY_GUI))
|
||||||
|
|
||||||
|
assert legacy_import.find_stale_upstream_launchers() == [str(entry)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_idle_callback_runs_the_check_once_and_then_stops(monkeypatch) -> None:
|
||||||
|
"""GLib repeats an idle callback while it returns truthy, so the wrapper must return False."""
|
||||||
|
from vocalinux import main as voculinux_main
|
||||||
|
|
||||||
|
calls: list[int] = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
legacy_import, "report_stale_upstream_launchers", lambda: calls.append(1) or []
|
||||||
|
)
|
||||||
|
|
||||||
|
assert voculinux_main._report_stale_upstream_launchers() is False
|
||||||
|
assert calls == [1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_idle_callback_swallows_a_failing_check(monkeypatch) -> None:
|
||||||
|
"""A failing check is logged and never escapes into the GTK main loop."""
|
||||||
|
from vocalinux import main as voculinux_main
|
||||||
|
|
||||||
|
def _boom() -> list[str]:
|
||||||
|
raise OSError("disk gone")
|
||||||
|
|
||||||
|
monkeypatch.setattr(legacy_import, "report_stale_upstream_launchers", _boom)
|
||||||
|
|
||||||
|
assert voculinux_main._report_stale_upstream_launchers() is False
|
||||||
|
|||||||
@@ -96,8 +96,11 @@ RETAINED_MODULE_HEADS = frozenset(
|
|||||||
|
|
||||||
#: The one upstream credit per fork surface, matched as whole stripped lines.
|
#: The one upstream credit per fork surface, matched as whole stripped lines.
|
||||||
CREDIT_LINE = "Voculinux is a hard fork of Vocalinux by VocaHQ: https://github.com/VocaHQ/vocalinux"
|
CREDIT_LINE = "Voculinux is a hard fork of Vocalinux by VocaHQ: https://github.com/VocaHQ/vocalinux"
|
||||||
|
README_CREDIT_LINE = (
|
||||||
|
"Voculinux is inspired by Vocalinux by VocaHQ: https://github.com/VocaHQ/vocalinux"
|
||||||
|
)
|
||||||
FORK_SURFACE_CREDITS: dict[str, frozenset[str]] = {
|
FORK_SURFACE_CREDITS: dict[str, frozenset[str]] = {
|
||||||
"README.md": frozenset({CREDIT_LINE}),
|
"README.md": frozenset({README_CREDIT_LINE}),
|
||||||
"CONTRIBUTING.md": frozenset({CREDIT_LINE}),
|
"CONTRIBUTING.md": frozenset({CREDIT_LINE}),
|
||||||
"src/vocalinux/ui/settings_about.py": frozenset(
|
"src/vocalinux/ui/settings_about.py": frozenset(
|
||||||
{
|
{
|
||||||
@@ -434,7 +437,7 @@ def test_allowlist_denies_user_facing_old_names() -> None:
|
|||||||
def test_allowlist_keeps_the_retained_surfaces() -> None:
|
def test_allowlist_keeps_the_retained_surfaces() -> None:
|
||||||
"""Upstream provenance, the retained package and the legacy import stay allowed."""
|
"""Upstream provenance, the retained package and the legacy import stay allowed."""
|
||||||
kept = [
|
kept = [
|
||||||
("README.md", CREDIT_LINE),
|
("README.md", README_CREDIT_LINE),
|
||||||
("CONTRIBUTING.md", CREDIT_LINE),
|
("CONTRIBUTING.md", CREDIT_LINE),
|
||||||
(
|
(
|
||||||
"src/vocalinux/ui/settings_about.py",
|
"src/vocalinux/ui/settings_about.py",
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "voculinux-website",
|
"name": "voculinux-website",
|
||||||
"version": "0.19.0",
|
"version": "0.19.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "voculinux-website",
|
"name": "voculinux-website",
|
||||||
"version": "0.19.0",
|
"version": "0.19.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "^20.14.10",
|
"@types/node": "^20.14.10",
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.3",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "voculinux-website",
|
"name": "voculinux-website",
|
||||||
"version": "0.19.0",
|
"version": "0.19.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const homeJsonLd = [
|
|||||||
},
|
},
|
||||||
description:
|
description:
|
||||||
"Offline voice dictation and speech-to-text for Linux with whisper.cpp, Faster Whisper, Whisper, VOSK, and Parakeet.",
|
"Offline voice dictation and speech-to-text for Linux with whisper.cpp, Faster Whisper, Whisper, VOSK, and Parakeet.",
|
||||||
softwareVersion: "0.19.0",
|
softwareVersion: "0.19.1",
|
||||||
author: {
|
author: {
|
||||||
"@type": "Person",
|
"@type": "Person",
|
||||||
name: "Xavier Karma",
|
name: "Xavier Karma",
|
||||||
|
|||||||
Reference in New Issue
Block a user