Skip to content

Merge pull request #84 from JRufer/claude/stop-tts-when-dictation-starts #75

Merge pull request #84 from JRufer/claude/stop-tts-when-dictation-starts

Merge pull request #84 from JRufer/claude/stop-tts-when-dictation-starts #75

Workflow file for this run

name: Release
# Trigger: every push to master builds and (re)creates the release for the
# current app version (read from src-tauri/tauri.conf.json). Bump the version
# there to start a fresh release; pushes at the same version refresh it.
# Can also be triggered manually with an explicit tag to (re-)build.
on:
push:
branches:
- master
workflow_dispatch:
inputs:
tag:
description: "Tag to (re-)build (e.g. v0.1.4). Defaults to the app version in tauri.conf.json."
required: false
type: string
# Only one release build per branch at a time; a newer push cancels an
# in-progress one so the release reflects the latest commit.
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: write # needed to create/update GitHub releases
env:
CARGO_HTTP_MULTIPLEXING: false
CARGO_HTTP_HTTP2: false
# ── Version consistency gate ───────────────────────────────────────────────────
# There is no single source of truth for the app version: it's declared
# separately in package.json, src-tauri/tauri.conf.json, and Cargo.toml's
# [workspace.package]. They HAVE drifted before (v0.1.8 shipped with
# package.json still at 0.1.7) and release tags have been cut that didn't
# match tauri.conf.json at all (v0.2.7 was tagged via a manual dispatch
# override while tauri.conf.json still said 0.2.6). Fail fast, before
# spending build minutes, if they disagree. Always bump via
# ./scripts/bump_version.sh so this can't happen.
jobs:
check-version-sync:
name: Check version files are in sync
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Compare package.json / tauri.conf.json / Cargo.toml
run: |
PKG_VERSION=$(jq -r '.version' package.json)
TAURI_VERSION=$(jq -r '.version' src-tauri/tauri.conf.json)
CARGO_VERSION=$(awk '/^\[workspace\.package\]/{f=1; next} /^\[/{f=0} f && /^version *=/{gsub(/"/,"",$3); print $3; exit}' Cargo.toml)
echo "package.json: $PKG_VERSION"
echo "tauri.conf.json: $TAURI_VERSION"
echo "Cargo.toml (workspace): $CARGO_VERSION"
if [ "$PKG_VERSION" != "$TAURI_VERSION" ] || [ "$PKG_VERSION" != "$CARGO_VERSION" ]; then
echo "::error::Version mismatch across package.json ($PKG_VERSION), src-tauri/tauri.conf.json ($TAURI_VERSION), and Cargo.toml ($CARGO_VERSION). Run ./scripts/bump_version.sh <version> to fix, then push again."
exit 1
fi
# A manually-dispatched tag must match the files' version — it
# exists to re-run/re-tag a build of the CURRENT version, not to
# publish a release under a version the app doesn't actually report.
if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "${{ inputs.tag }}" ]; then
EXPECTED="v${PKG_VERSION}"
if [ "${{ inputs.tag }}" != "$EXPECTED" ]; then
echo "::error::Manual tag '${{ inputs.tag }}' does not match the version in the source files ($EXPECTED). Bump the version files to match the tag you want (./scripts/bump_version.sh), or dispatch with tag=$EXPECTED."
exit 1
fi
fi
# ── Build matrix ──────────────────────────────────────────────────────────────
build:
needs: check-version-sync
name: Build ${{ matrix.name }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false # let all variants finish even if one fails
matrix:
include:
# ── Linux CPU ──────────────────────────────────────────────────────
- name: linux-cpu
os: ubuntu-22.04
features: ""
artifact_label: linux-x86_64
cuda: false
# ── Linux Vulkan ───────────────────────────────────────────────────
# Portable GPU AppImage: accelerates NVIDIA/AMD/Intel via the host
# Vulkan driver, with CPU fallback. Far lighter to bundle than CUDA.
- name: linux-vulkan
os: ubuntu-22.04
features: "vulkan"
artifact_label: linux-x86_64-vulkan
cuda: false
vulkan: true
# ── Windows CPU ────────────────────────────────────────────────────
# DISABLED: the Windows build is the slowest job in the matrix and
# nobody can test what it produces at the moment, so it is not worth
# the wait on every push to master.
#
# To re-enable: uncomment the five lines below, and put the Windows
# row back in the "## Downloads" table in the release body at the
# bottom of this file. Every Windows-specific *step* further down
# (`if: runner.os == 'Windows'`) is left in place and simply never
# runs while no Windows entry exists in this matrix — nothing else
# needs changing.
# - name: windows-cpu
# os: windows-2022
# features: ""
# artifact_label: windows-x86_64
# cuda: false
steps:
- uses: actions/checkout@v4
# ── System deps (Linux) ─────────────────────────────────────────────────
# squashfs-tools (unsquashfs) is required for APPIMAGE_EXTRACT_AND_RUN=1
# which lets appimagetool/linuxdeploy run without FUSE on CI runners.
- name: Install Linux system dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update -q
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
libssl-dev \
libasound2-dev \
libspeechd-dev \
libxdo-dev \
patchelf \
squashfs-tools \
pkg-config \
build-essential
# ── Vulkan SDK (Linux) ──────────────────────────────────────────────────
# The ggml Vulkan backend compiles its compute shaders at build time with
# glslc, which ships in the LunarG Vulkan SDK. End-users only need their
# GPU's Vulkan driver at runtime (bundled loader finds the host ICD).
- name: Install Vulkan SDK (Linux)
if: runner.os == 'Linux' && matrix.vulkan
run: |
wget -qO- https://packages.lunarg.com/lunarg-signing-key-pub.asc \
| sudo tee /etc/apt/trusted.gpg.d/lunarg.asc >/dev/null
sudo wget -qO /etc/apt/sources.list.d/lunarg-vulkan-jammy.list \
https://packages.lunarg.com/vulkan/lunarg-vulkan-jammy.list
sudo apt-get update -q
sudo apt-get install -y vulkan-sdk
glslc --version
# ── Rust ────────────────────────────────────────────────────────────────
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
key: release-${{ matrix.name }}
# ── Node / frontend ─────────────────────────────────────────────────────
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
- run: npm install
# ── Tauri build (Linux) ─────────────────────────────────────────────────
# APPIMAGE_EXTRACT_AND_RUN=1 tells linuxdeploy/appimagetool to extract
# and run without FUSE (uses unsquashfs from squashfs-tools instead).
# QT_QPA_PLATFORM=offscreen prevents Qt platform errors in headless CI.
- name: Build AppImage + deb (Linux, CPU)
if: runner.os == 'Linux' && !matrix.cuda && !matrix.vulkan
env:
APPIMAGE_EXTRACT_AND_RUN: 1
QT_QPA_PLATFORM: offscreen
# Moonshine (STT) and Inflect-Micro (TTS) are default features, so both
# ONNX engines are in every build without naming them here.
run: npm run tauri build
# Portable GPU AppImage. whisper.cpp's Vulkan backend links only
# libvulkan (lightweight, unlike CUDA), so linuxdeploy bundles it fine.
- name: Build AppImage + deb (Linux, Vulkan)
if: runner.os == 'Linux' && matrix.vulkan
env:
APPIMAGE_EXTRACT_AND_RUN: 1
QT_QPA_PLATFORM: offscreen
run: npm run tauri build -- --features vulkan
# linuxdeploy bundles whatever graphics/Wayland/input libraries exist on
# the build host. On ubuntu-22.04 those are older than modern desktops, and
# loading them instead of the host's copies breaks the Slint overlay's
# Wayland frame callbacks (it renders once, then the compositor never sends
# another frame) and trips "unrecognized keysym" xkbcommon errors. Strip
# them so the AppImage falls through to the host's libraries at runtime —
# this is what the AppImage excludelist exists for, and why a local
# build_appimage.sh (which bundles the user's own newer libs) works while
# the CI build did not. libvulkan is intentionally left bundled.
- name: Slim AppImage to host graphics/input libraries (Linux)
if: runner.os == 'Linux' && !matrix.cuda
env:
APPIMAGE_EXTRACT_AND_RUN: 1
run: |
set -euo pipefail
appimage=$(find . -path '*/bundle/appimage/*.AppImage' ! -name '*.zsync' | head -1)
if [ -z "${appimage:-}" ]; then echo "No AppImage found to slim"; exit 1; fi
echo "Slimming $appimage"
chmod +x ./appimagetool.bin
work=$(mktemp -d)
cp "$appimage" "$work/in.AppImage"
chmod +x "$work/in.AppImage"
( cd "$work" && ./in.AppImage --appimage-extract >/dev/null )
root="$work/squashfs-root"
# Bundle WebKitGTK 4.1 helper processes (WebKitNetworkProcess, WebKitWebProcess)
helper_dir=""
for dir in "/usr/lib/x86_64-linux-gnu/webkit2gtk-4.1" "/usr/lib/webkit2gtk-4.1" "/usr/lib64/webkit2gtk-4.1"; do
if [ -d "$dir" ]; then
helper_dir="$dir"
break
fi
done
# The bundled libwebkit2gtk has its helper-process directory compiled
# in as /usr/lib/x86_64-linux-gnu/webkit2gtk-4.1, which the Tauri
# bundler rewrites to ././/lib/x86_64-linux-gnu/webkit2gtk-4.1 — a
# path relative to the working directory, which linuxdeploy's AppRun
# sets to $APPDIR/usr. Release builds of WebKitGTK honour no
# environment override for it (WEBKIT_EXEC_PATH exists only in
# developer-mode builds), so the helpers must sit at exactly that
# relative path. WEBKIT_INJECTED_BUNDLE_PATH is honoured, but as a
# single directory, not a colon-separated search path.
if [ -n "$helper_dir" ]; then
echo "Found WebKitGTK helper processes in $helper_dir, bundling into AppImage..."
mkdir -p "$root/usr/lib/x86_64-linux-gnu/webkit2gtk-4.1"
cp -r "$helper_dir"/* "$root/usr/lib/x86_64-linux-gnu/webkit2gtk-4.1/"
fi
for script in "$root/AppRun" "$root/apprun-hooks/linuxdeploy-plugin-gtk.sh"; do
[ -f "$script" ] || continue
sed -i '/^export WEBKIT_EXEC_PATH=/d; /^export WEBKIT_INJECTED_BUNDLE_PATH=/d' "$script"
sed -i '2i export WEBKIT_EXEC_PATH="${APPDIR}/usr/lib/x86_64-linux-gnu/webkit2gtk-4.1"' "$script"
sed -i '3i export WEBKIT_INJECTED_BUNDLE_PATH="${APPDIR}/usr/lib/x86_64-linux-gnu/webkit2gtk-4.1/injected-bundle"' "$script"
done
# pocket_tts (the Pocket-TTS and Breeze-TTS-2 engines) finds its model
# config at config/<variant>.yaml relative to the working directory.
# Bundle it at usr/config so those engines load without changing the
# process's cwd — which, per the note above, WebKitGTK depends on.
mkdir -p "$root/usr/config"
cp crates/voxctrl-tts/config/*.yaml "$root/usr/config/"
# Some libraries can be neither stripped nor left on the library path, so
# they are parked in usr/lib/fallback instead: the AppRun hook exposes each
# one only when the host has no library of that soname, so a host with its
# own copy always wins.
#
# libsystemd / libudev — the host's own libmount (Arch) links libsystemd
# and needs a newer LIBSYSTEMD_* version node than the build host's
# copy, while non-systemd distributions may not ship them at all and the
# bundled WebKitGTK needs them.
# libgstgl-1.0 / libwayland-server — the bundled WebKitGTK links both
# directly, and the strip patterns below would delete them.
# libgstgl-1.0.so.0 ships in libgstreamer-gl1.0-0, which
# gstreamer1.0-plugins-base does not depend on, so a desktop with no
# WebKit of its own can be missing it entirely and the app then dies
# with "error while loading shared libraries: libgstgl-1.0.so.0".
#
# This has to run before the strip loop below, which skips usr/lib/fallback.
# Keep this list in sync with build_appimage.sh.
mkdir -p "$root/usr/lib/fallback"
for pat in 'libsystemd.so*' 'libudev.so*' \
'libgstgl-1.0.so*' 'libwayland-server.so*'; do
find "$root" -name "$pat" -not -path '*/fallback/*' -print \
-exec mv -t "$root/usr/lib/fallback/" {} + 2>/dev/null || true
done
echo "Host-first fallback libraries:"; ls -la "$root/usr/lib/fallback"
# Everything below is deleted so the AppImage falls through to the
# host's copy. The rule: once a library comes from the host, every
# library it links against must come from the host too, or the
# host's copy resolves its symbols against our stale bundled one and
# aborts at startup (seen in the wild: GStreamer needing
# g_once_init_leave_pointer from GLib 2.80; GLib needing MOUNT_2_40
# from libmount; GIO's TLS module needing GNUTLS_3_8_x). A library
# may only be stripped if its soname is stable across distros —
# libxml2 (.so.2 vs .so.16 on Arch) and libunistring (.so.2 vs
# .so.5) are deliberately kept bundled for that reason. Everything
# still bundled only needs the build host's (Ubuntu 22.04) versions
# of these, so any newer host satisfies them.
for pat in \
'libwayland-*.so*' 'libEGL.so*' 'libGL.so*' 'libGLX.so*' \
'libGLdispatch.so*' 'libOpenGL.so*' 'libglapi.so*' \
'libgbm.so*' 'libdrm.so*' \
'libxkbcommon.so*' 'libxkbcommon-x11.so*' \
`# GLib family, and what the host's GLib/GIO link against` \
'libglib-2.0.so*' 'libgobject-2.0.so*' 'libgio-2.0.so*' \
'libgmodule-2.0.so*' 'libgthread-2.0.so*' \
'libpcre2-8.so*' 'libpcre.so*' 'libffi.so*' \
'libmount.so*' 'libblkid.so*' 'libselinux.so*' \
`# GIO loads the host's TLS module, which needs the host's gnutls stack` \
'libgiognutls.so' 'libgnutls.so*' 'libhogweed.so*' 'libnettle.so*' \
'libtasn1.so*' 'libp11-kit.so*' 'libidn2.so*' \
`# GStreamer, and what the host's GStreamer links against` \
'libgstreamer-*.so*' 'libgst*.so*' 'liborc-0.4.so*' \
'libunwind.so*' 'libdw.so*' 'libelf.so*' \
'libbz2.so*' 'liblzma.so*' 'libzstd.so*'; do
find "$root" -name "$pat" -not -path '*/fallback/*' -print -delete 2>/dev/null || true
done
# Install the AppRun hook that exposes usr/lib/fallback host-first.
cp scripts/appimage-hooks/host-first-fallback.sh "$root/apprun-hooks/"
chmod +x "$root/apprun-hooks/host-first-fallback.sh"
if ! grep -q "host-first-fallback.sh" "$root/AppRun"; then
sed -i '/^exec /i source "$this_dir"/apprun-hooks/host-first-fallback.sh' "$root/AppRun"
fi
# Repackage. appimage-pack.sh embeds a runtime that also works on
# hosts without libfuse2 (Ubuntu 22.04 / Linux Mint 21 and newer ship
# fuse3 only), falling back to appimagetool's own runtime if it
# cannot fetch one, and verifies the result unpacks before returning.
./scripts/appimage-pack.sh "$root" "$appimage" ./appimagetool.bin
echo "Repackaged $appimage ($(du -h "$appimage" | cut -f1))"
# CUDA build uses --bundles deb only: the CUDA toolkit populates system
# library paths with hundreds of CUDA .so files that linuxdeploy tries to
# bundle into the AppImage, causing it to OOM or fail. deb packaging does
# not use linuxdeploy and works reliably. CUDA users on Linux are almost
# always on Debian/Ubuntu and can install the .deb directly.
# ── Tauri build (Windows) ───────────────────────────────────────────────
# NSIS (setup.exe) only — the advertised Windows installer. The WiX/MSI
# bundler's `light.exe` fails to harvest the voxctrl-overlay sidecar, and
# the MSI is a secondary, unadvertised artifact. Call tauri directly so
# `--bundles nsis` reaches tauri instead of cargo.
- name: Build installer (Windows, CPU)
if: runner.os == 'Windows'
run: npx tauri build --bundles nsis
# ── Collect and rename Linux artifacts ──────────────────────────────────
# Search the whole workspace so the path is correct regardless of whether
# cargo uses a workspace-level or package-level target directory.
- name: Collect Linux artifacts
if: runner.os == 'Linux'
run: |
mkdir -p upload
find . \( -name "*.AppImage" -o -name "*.deb" \) \
! -name "*.AppImage.zsync" \
! -path "*/.cargo/*" | \
while read -r f; do
base=$(basename "$f")
stem="${base%.*}"
ext="${base##*.}"
dest="upload/${stem}-${{ matrix.artifact_label }}.${ext}"
cp "$f" "$dest"
echo "Collected: $dest ($(du -sh "$f" | cut -f1))"
done
ls -lh upload/
# ── Collect and rename Windows artifacts ────────────────────────────────
# Search the whole workspace so the path is correct regardless of where
# Tauri/cargo places the bundle directory.
- name: Collect Windows artifacts
if: runner.os == 'Windows'
shell: pwsh
run: |
New-Item -ItemType Directory -Force upload | Out-Null
Get-ChildItem -Path . -Recurse -Include "*.exe","*.msi" -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -like "*\bundle\*" -and $_.Name -notlike "*-stub*" } |
ForEach-Object {
$dest = "upload\$($_.BaseName)-${{ matrix.artifact_label }}$($_.Extension)"
Copy-Item $_.FullName $dest
$size = "{0:N1} MB" -f ($_.Length / 1MB)
Write-Host "Collected: $dest ($size)"
}
if ((Get-ChildItem upload).Count -eq 0) {
Write-Host "ERROR: No bundle files found. Searching for any .exe/.msi to diagnose:"
Get-ChildItem -Path . -Recurse -Include "*.exe","*.msi" -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -notlike "*\.cargo*" } |
Select-Object -ExpandProperty FullName
exit 1
}
# ── Upload to Actions (picked up by publish job) ─────────────────────────
- uses: actions/upload-artifact@v4
with:
name: artifacts-${{ matrix.name }}
path: upload/*
if-no-files-found: error
retention-days: 1
# ── Publish release ───────────────────────────────────────────────────────────
publish:
name: Publish GitHub release
needs: build
runs-on: ubuntu-latest
# Run even if some build variants failed so partial releases still publish
if: always() && contains(needs.build.result, 'success')
steps:
# Needed to read the app version for the default tag name.
- uses: actions/checkout@v4
- name: Resolve tag name
id: tag
run: |
# Manual dispatch may override the tag; otherwise derive it from the
# app version (e.g. 0.2.4 -> v0.2.4).
if [[ "${{ github.event_name }}" == "workflow_dispatch" && -n "${{ inputs.tag }}" ]]; then
echo "name=${{ inputs.tag }}" >> "$GITHUB_OUTPUT"
else
VERSION=$(jq -r '.version' src-tauri/tauri.conf.json)
echo "name=v${VERSION}" >> "$GITHUB_OUTPUT"
fi
# Download everything produced by the build matrix
- uses: actions/download-artifact@v4
with:
path: release-artifacts
merge-multiple: true
- name: List release artifacts
run: ls -lh release-artifacts/
- name: Create / update draft release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.tag.outputs.name }}
name: "VoxCtrl ${{ steps.tag.outputs.name }}"
draft: true
generate_release_notes: true
fail_on_unmatched_files: true
files: release-artifacts/*
# The "What's new" section below is version-specific: update it in the
# same commit that bumps the version (scripts/bump_version.sh), so a
# release never ships the previous version's notes. Everything under
# "## Downloads" is evergreen, and GitHub appends its own generated
# "What's Changed" list of merged PRs beneath all of this.
#
# The Downloads table lists no Windows build because the Windows job
# is commented out of the build matrix above; a row for a file this
# workflow does not produce would send people looking for a download
# that is not there. Restore the row when the job comes back:
# | Windows | `-windows-x86_64-setup.exe` | None |
# Note this is a literal YAML block scalar — a `#` inside `body:`
# would be published as text, which is why the row is removed rather
# than commented out.
body: |
## What's new in 0.4.0
The largest release so far: a new machine is walked through setup instead of
dropped into a settings window, VoxCtrl can update itself, output targets are
now Output Commands and say how to use them, and a settings audit removed or
implemented every control that was doing nothing.
### First-run setup wizard
A first launch used to open Settings on a config nobody had chosen — the
default model downloading in the background, a `Super+Space` binding the user
never saw, and no sign whether any of it worked. The first thing that told you
something was wrong was pressing a key and getting silence.
A fresh install now gets a seven-step wizard in its own window: pick a
transcription engine and model size (downloaded before you continue), bind a
hotkey and register it with your desktop, choose an overlay, dictate a live
test, add a voice, and finish on a screen that reports anything that failed.
Every choice is written to the config as it is made, so quitting halfway keeps
what you picked.
- The hotkey step offers only gestures your desktop's shortcut backend can
actually deliver, and will not let you past until the desktop has *accepted*
the binding — because the next screen is a live dictation test that would
otherwise be guaranteed to fail.
- The test step is genuinely end to end: the transcript is typed into the
wizard's own textarea down the real delivery path.
- The first binding is pointed at a "Command" output, so voice commands work
the day you add a second one, with nothing to re-bind.
- The window sizes itself to the display it opens on, so a 1080p desktop at
125% scaling no longer puts the Continue button past the bottom edge.
Run it again any time with `voxctrl --setup`, or Settings → General → "Open
setup wizard". An upgrade never triggers it: a config file written by an
earlier VoxCtrl reads as already set up.
### VoxCtrl updates itself
An AppImage sits where you downloaded it, so until now the only way to update
was to notice the release yourself and fetch the right file by hand.
Ten seconds after launch, VoxCtrl asks GitHub what the latest release is. If
there is a newer one, a window shows the version, the release notes and the
download size, and offers **Update and restart**, **Skip this version** or
**Not now**. Confirming downloads the build matching your installation — CPU
AppImage or Vulkan AppImage — verifies it against the SHA-256 checksum GitHub
published, replaces the running file and restarts into it.
Nothing is replaced until a complete, verified file is on disk beside the one
it replaces, and the swap is a single rename. A failed download, a checksum
mismatch or a directory you cannot write to each leave the working version
exactly as it was, and "cannot write to" is detected *before* the download
rather than after 100 MB of it. A `.deb`, a distro package or a build from
source is told why it cannot self-update instead of being given a button that
cannot work.
**On privacy:** this is the first thing VoxCtrl does on the network without
being asked. It is one unauthenticated GET to the public releases API, sending
a `User-Agent` of `VoxCtrl/<version>` and nothing else — no cookie, no account,
no install ID, nothing about you or your machine. Settings → General →
"Check for a new version on launch" turns it off, as does one click in the
update window; with it off VoxCtrl makes no request unless you press "Check
now". docs/privacy.md documents exactly what is sent and how to watch it with
`tcpdump`.
### Output Targets are now Output Commands
"Output Targets" described where text lands, which is the implementer's view.
What you actually do with one is say its name mid-dictation — and nothing in
the app ever said so.
The tab is now **Output Commands**, and it opens with the thing you need to
know: start dictation, say *"VoxCtrl"*, then the command's name, then your
text. *"VoxCtrl notes, remember to call the plumber"* sends the rest to the
command named **notes**. Dictation with no such phrase in it goes wherever your
hotkey already points.
Nothing on disk changed: still `targets.toml`, still `[[target]]`, still
`target_id` in bindings. No existing config, script or integration is touched.
### Settings that were doing nothing — implemented or removed
An audit found controls that had never been wired to anything. Each was taken
in the direction it deserved.
**Now real:**
- **Noise suppression** runs RNNoise on the capture path, and the toggle
applies without a restart.
- **MCP record timeout** is now the default `transcribe_voice` listens for when
the caller omits one, instead of a hardcoded 15 seconds, and is advertised in
the tool schema.
- **Breeze-TTS-2 GPU** loads the model onto a CUDA or Metal device (opt-in at
build time; falls back to CPU with a warning).
- **File targets choose their timestamp format**, validated as you type, with
a bad pattern falling back to the default rather than costing you a
dictation.
**Removed, because nothing read them:** the AT-SPI2 section, Inference mode,
Silence duration, the TTS custom-vocabulary box, Breeze's temperature slider,
quiet mode, the Backend "Auto-detect" entry (it always resolved to
whisper.cpp), and four unwired options in the target editor. Existing
`config.json` and `targets.toml` files keep loading — the removed keys are
simply ignored.
### Fixes and smaller changes
- **Dictation no longer appends a newline.** It appends a single trailing space
instead, so consecutive utterances do not run their words together; text that
already ends in whitespace is left alone.
- **The delivery-type dropdown is no longer clipped** by the editor's scrolling
body — it could previously cut off mid-list with the rest unreachable.
- **Clicking the tray now actually raises Settings** when it is open behind
another window, instead of blinking the taskbar entry.
- **Windows close when you close them**, and the app carries on in the tray
rather than treating the last window as a quit.
- **The HuggingFace token is asked for once** in the wizard and stored once; an
exported `HF_TOKEN` wins and is never written to the config.
- The settings sidebar sizes itself to its longest label instead of clipping it
on wide system fonts.
- The transcript history feature is gone.
### Windows builds are paused
**This release has no Windows download.** The Windows job is the slowest leg of
the build and nothing it produced was being tested, so it is commented out of
the release workflow rather than removed — restoring it is uncommenting five
lines. Windows support in the code is untouched; only the automated build is
off. Build from source in the meantime.
### Requirements
glibc 2.35 or newer and a GCC 12 libstdc++ — Ubuntu 22.04+, Linux
Mint 21+, Debian 12+, Fedora 36+, Arch, openSUSE Tumbleweed. Ubuntu
20.04, Mint 20, Debian 11 and RHEL 9 are below that baseline; build
from source there.
---
## Downloads
Pick the build for your platform and GPU:
| Platform | File suffix | GPU required |
|---|---|---|
| Linux | `-linux-x86_64.AppImage` | None |
| Linux (GPU) | `-linux-x86_64-vulkan.AppImage` | Any Vulkan GPU |
**The Vulkan AppImage** is a portable GPU build: it accelerates any
NVIDIA/AMD/Intel GPU via the host Vulkan driver, and falls back to
CPU when no GPU is present.
**CPU builds** run on any hardware, including AMD, Intel, and ARM.
### Linux CPU / GPU — first run
Needs glibc 2.35 or newer (Ubuntu 22.04+, Linux Mint 21+, Debian
12+, Fedora 36+, Arch). Nothing has to be installed first — not
even `libfuse2`.
```bash
chmod +x VoxCtrl_*-linux-x86_64*.AppImage
# Optional: installs the desktop entry and text-injection helpers.
# Global shortcuts need no permissions — your desktop handles them.
./VoxCtrl_*-linux-x86_64*.AppImage --install
# Launch the application
./VoxCtrl_*-linux-x86_64*.AppImage
```