Close unauthenticated remote mutation paths and keep filesystem destinations behind one-shot Tauri capabilities. CodeRabbit and Greptile findings were fixed on-branch; all review threads are resolved. Full CI, Security, Rust, and cross-platform smoke checks are green.
The About page reported 'Compute device: cpu / GPU active: no / VRAM 0.00 GB' on a machine with a working GPU. Every line was true and none was usable — it is also exactly what a machine with no GPU at all reports, so the report could not distinguish a driver that isn't loaded from a container that cannot open the device from a ROCm older than the card.
The probe already knew all of it; torch.cuda.is_available() returning False simply produced no note. Each cause now reads differently: a missing device node names the --device flags, a permissions failure names --group-add and how to find the host's real render/video GIDs (copied numbers are the most common way this ends up on CPU in Docker), a card newer than the shipped ROCm points at rocminfo, an HSA_OVERRIDE_GFX_VERSION that is doing more harm than good is named first because it is both likelier and cheaper to test, and an unreachable NVIDIA driver gets its own advice.
The probe never diagnoses from a measurement it did not complete: when torch.cuda.is_available() itself raises, the exception is reported and no device findings are asserted beside it. Metadata access that raises is contained too — this runs on the path whose whole job is to explain a failure, so it cannot become one.
Every subprocess engine confirms a candidate interpreter by spawning it and importing the engine package. For IndexTTS that is 'import indextts.infer_v2', which pulls in torch and transformers — seconds with a warm page cache, tens of seconds on a first run, a spinning disk, a network share, or Windows with real-time AV scanning every DLL.
The bound was 10s (15s for three peers), and elapsing it was treated as a negative: the candidate was discarded exactly as if the import had raised. A working OMNIVOICE_INDEXTTS_DIR install was reported as 'IndexTTS-2 is not installed', or fell through into the lazy bootstrap and reinstalled over a working clone. Only successful resolution was memoised, so every retry re-ran the probe and failed identically — which is why all three reported repro paths look like one bug.
A timeout is the absence of evidence, not evidence of breakage. The probe is now tri-state: yes (imported), no (ran and failed), unproven (did not finish). An unproven candidate is kept as a fallback and used only after every candidate has had its chance, so a wedged user clone cannot shadow a healthy bootstrapped venv. If an unproven venv really is broken it now fails at the sidecar handshake with a real error rather than a confident lie about the install.
Fixed as a class: backend/engines/_venv_probe.py replaces the drifted copy in each of the four bootstraps, and the bound is tunable per engine, defaulting to 60s. Zero and negative values are ignored — an unbounded probe would let one wedged candidate hang engine resolution forever.
Reported with a precise root cause by @OracleNightmare. (#1414)
Two bugs from the v0.4.2 rename sweep:
- The lazy model import was rewritten to `from omnivoice.models.omnivoice import VoiceStudio`, a class the library does not export. ImportError is not ModuleNotFoundError, so the #564 source fallback never caught it and /generate 500'd on every default-engine request. The class keeps its library name — it is a checkpoint-referenced identifier, not branding.
- alembic resolved a bare relative script_location against the process cwd, and the desktop shell launches the backend from frontend/src-tauri, so a pending migration killed startup. script_location and prepend_sys_path are now anchored with %(here)s, with path_separator = os so Windows drive letters and paths with spaces survive. alembic floor raised to >=1.16.
Regression tests verified fail-before/pass-after for both.
Portable mode put everything in OmniVoiceStudio-Data beside the app — the only storage row on the setup screen you could look at but not change, while installed mode had a picker for all three of its directories.
The pin had a reason: portable_base() is computed from the executable's location and never read from config, which is what makes a portable install self-discovering. A user-chosen folder breaks that, because the only record of the location would live inside the folder being located.
So it is recorded somewhere findable, in order: a portable.path marker beside the app; then portableDir in the per-user config for app folders that are read-only; then the historical default, so existing portable installs resolve byte-identically.
The marker stores a RELATIVE path whenever the folder sits inside the app's own directory — the USB-stick case portable mode exists for, where app and folder move as a unit and the mount path is free to change. Anywhere else only an absolute path can be stored, and the setup screen says the install is tied to it rather than promising portability it cannot keep.
Two traps worth naming, both caught in review: the per-user fallback would recurse forever through load_config (config_path to portable_config_file to portable_base), so it reads the platform config file directly; and clear_portable_dir was reading through that same chain, silently failing to clear the machine record so the old folder kept winning after the user chose the default.
This also retires the greyed-out Portable option after a default Program Files install (#766) — you point it at a writable disk and get the machine-bound variant.
Rust 109 passed, frontend 1705 passed. All 21 locales in lockstep; docs/install/windows.md rewritten.
The dictation hotkey could leave a blank dark square stuck on the desktop with no way to dismiss it. Three defects compounded: the tray listener's effect depended on [state], so it detached across an await on every state change and a press landing in that gap was lost; an idle pill renders null, so the window Rust had already shown was empty; and the opaque chrome background made that empty window a hard-edged square. Nothing could hide it — dismiss() is only reachable from the X button, Esc, or a post-session timer, none of which exist for a session that never started.
Fixed at the invariant rather than the call sites: the listener subscribes once for the component's lifetime, the widget window's chrome background is transparent, and an idle-but-visible window reconciles itself to hidden. The reconcile is polled (a dropped press changes no React state, so there is nothing to key an effect off) and aborts if its effect is torn down mid-check, so it can never hide a dictation that has just started.
Also in scope:
- The rename sweep had repointed three data-dir literals at a brand-named directory that does not exist, so smoke-test.sh verified a directory the backend never writes and desktop-prod.sh silently stopped clearing backend state on Windows. Both invisible on macOS, where they are usually run. A guard test now pins the assignments specifically.
- The dictation model picker's download sizes were wrong for all seven models, in both directions — Parakeet TDT v3 (the recommended default) understated 180 MB against an actual 670 MB, while the low-RAM fallbacks were overstated threefold, discouraging exactly the choice that would have helped. Measured from the published repos and pinned by a test.
- The 0.6B Parakeet models now decode on more threads, capped by host cores and still overridable.
- uninstall.ps1 gained a UTF-8 BOM (Windows PowerShell 5.1 mis-decodes its non-ASCII output without one), and sponsor.yml lost its last OmniVoice references.
Scaffolds the per-repo agent configuration the engineering skills assume: where issues live (GitHub Issues via the gh CLI), the five canonical triage labels, and the single-context domain-doc layout. Adds docs/agents/{issue-tracker,triage-labels,domain}.md and an '## Agent skills' block in both CLAUDE.md and AGENTS.md so the two stay in sync.
Documentation only — no runtime code is touched.
Review fixes: CONTEXT.md is now described as 'read it when it exists' rather than stating its absence as permanent, and both directory-tree fences carry a language identifier (markdownlint MD040).
CTranslate2 (the engine under WhisperX and faster-whisper) requires cuDNN 8 while torch ships cuDNN 9. When the side-loaded cuDNN 8 is absent it does not raise — it __fastfail()s, killing the whole backend with 0xC0000409 and no traceback, so the shell restarts it and the next attempt dies the same way.
The defect was that the backend computed the answer and discarded it: the preload passed silently on a missing directory and on every OSError, then called into a library that treats the same condition as fatal. The answer is now kept, and the CTranslate2 engines report themselves unavailable so auto-detect falls through to pytorch-whisper.
Two more instances of the same class went with it: the crash-isolated ASR sidecar is a child process that never preloaded at all (failing every transcribe, quietly), and the preload only searched <project root>/.venv, missing any other interpreter.
Conservative by design — a false positive costs WhisperX's forced alignment, so ROCm is excluded via torch.version.hip before cuda.is_available(), which is True on HIP builds.
14 regression tests; backend suite 4374 passed.
Renames what users see. The app, the installers, the window title, the
docs and all 21 locales now say VoiceStudio, with "(previously
OmniVoice-Studio)" noted near the title of each doc surface so people
recognise it.
Deliberately NOT renamed, because renaming any of them silently breaks
an existing install — there is no legacy-path fallback anywhere in this
codebase:
- bundle identifier com.debpalash.omnivoice-studio (MSI UpgradeCode,
macOS TCC grants, managed venv, WebView localStorage, the
single-instance lock)
- data directories OmniVoice / .omnivoice and omnivoice.db
- the ~150 OMNIVOICE_* environment variables
- the X-OmniVoice-* HTTP headers (a wire protocol)
- the published Docker image paths
- the OmniVoice ENGINE, which is a model name and not this product
tests/test_identity_paths_survive_the_rename.py pins every one of those
so a future well-meaning sweep cannot orphan a user's library.
Linux .deb users install a new package name and should apt remove
omnivoice-studio; that note is in the changelog.
The repository was renamed. 724 references across 59 files now point at the new URL — README badges, docs, install guides, the updater's releases API call, CONTRIBUTING, the Colab link and the probe harness. GitHub redirects the old URLs, so nothing was broken in the meantime.
Deliberately NOT renamed, because each breaks something on a user's machine: the Tauri bundle identifier (the path to every existing user's data), /usr/lib/omnivoice-studio and the compose container names, and the published Docker image paths.
The image path needed a code change to STAY still: docker.yml derived it from github.repository, so the next build would have published to ghcr.io/debpalash/voicestudio while Docker Hub, a hardcoded literal, stayed put — everyone pulling the documented GHCR path would have kept receiving the last pre-rename image forever. It is now pinned, with a test that fails if it ever derives from the repo name again.
Also makes the probe's repo-name assertion shape-based: it hardcoded the old name and failed on every PR after the rename while the code it tests worked perfectly.
A meticulous report from an LXC/CPU-only source install surfaced three real defects: the build script deleted the libggml shared libraries a dynamically-linked build needs (first spawn died with exit 127), the hardcoded 120s per-spawn kill switch reaped legitimate CPU-only renders, and OMNIVOICE_ALLOWED_ORIGINS — the only fix for cross-origin browser access — was documented nowhere.
All platform branches of scripts/build-omnivoice-tts.sh now copy the shared libs next to the binary, the CI artifact glob uploads them, and the backend puts bin/ on the loader path for every spawn of the engine binary. The timeout defaults to 600s (above the pool guard's well-diagnosed 300s deadline), is tunable via OMNIVOICE_GGUF_GENERATE_TIMEOUT_S with non-finite values rejected, and the timeout error names the knob. CORS documented in api-auth.md with a pointer from remote-gpu.md. Regression tests pin the spawn-env rule, the per-branch copy rule, the artifact glob, and the timeout behavior.
The TRANSFORMERS_IMPORT hint and the ASR pipeline error told users to reinstall torch + torchaudio + transformers. torchvision — the package whose ABI mismatch actually produces this exact lazy-import wording (#1357's torchvision::nms, wrapped into "Could not import module 'AutoFeatureExtractor'") — was the one package the advice omitted. Following it to the letter left the broken package untouched (#1376).
Both surfaces now name the mismatch as a cause and prescribe the pinned reinstall with literal versions (desktop installs ship no deploy/, so the constraint-file form fails there) targeting the venv explicitly. A lockstep test asserts the exact command on every advice surface against deploy/torch-constraints.txt, so a pin bump stays red until the advice matches. Docs gain the same-wording-different-cause section (1a-bis).
* feat(engines): PocketTTS CPU-only sidecar shape (#1306)
Sidecar SHAPE for review, mirroring omnivoice-subprocess: PocketTTSBackend(SubprocessBackend) (CPU-only, parent interpreter, optional-dep gate) plus a stdio sidecar (ready/ping/synthesize/shutdown, lazy TTSModel.load_model, per-ref voice cache, generate_audio to int16 PCM). Registered in services/tts_backend.py. Batch protocol; streaming raised as a follow-up. CI smoke, gated-weights preflight, 4-platform install, licence-accept gate deferred to on-top after shape review.
* feat(engines): PocketTTS sidecar handles 6 languages (en/fr/de/pt/it/es)
load_model(language=...) per language (cached), maps OmniVoice's language value to a pocket-tts model language, and picks the default preset voice per language when no ref clip is given. Represents PocketTTS accurately: it is multilingual, not english-only. The HF model card's 'English only' line is stale, confirmed by the GitHub README and pocket-tts 2.1.0.
* fix(engines): list pockettts in docs inventory; drop unused logger
docs/features.yaml tts_engines now includes pockettts, clearing the docs-drift test that failed CI (every registered engine must be in the inventory). Removed the unused logger line CodeQL flagged. No readme/doc entry yet, matching opt-in engines like supertonic3 and omnivoice-gguf; a doc page can land with the rest of the integration.
* fix(engines): address PocketTTS sidecar review findings
- Cold-load watchdog: heartbeat progress frames during the gated weights download so the parent does not kill a healthy sidecar mid-load, plus a 600s recv timeout on the backend.
- Unsupported language: raise a clear error instead of silently falling back to English and mispronouncing.
- Voice-state cache: LRU-bounded to 8 entries so a long session cannot leak memory.
- ref_audio SSRF: reject URLs (local file paths only) to preserve local-first.
Addresses the 3 Greptile P1 + 1 CodeRabbit Major on #1328.
* fix(engines): invalidate voice cache on ref-file change; reject non-finite recv timeout
- Voice-state cache key now folds the ref_audio file mtime+size, so a file replaced at the same path no longer returns a stale voice from the previous contents (Greptile P1).
- recv_timeout_s rejects inf/nan env values via math.isfinite and falls back to 600s, so the deadline can't be silently disabled (CodeRabbit Major).
* fix(engines): nanosecond mtime in voice cache fingerprint
int(st.st_mtime) lost sub-second precision, so a file replaced at the same path within one second with the same size kept the old key and returned a stale voice. Use st.st_mtime_ns for full resolution (Greptile P1 on the follow-up fix commit).
* fix(engines): raise on multi-channel audio instead of unsafe downmix
The defensive mean(axis=0) assumed channels-first; on channels-last (N,2) it averaged across time, producing garbage. The engine returns mono, so the branch is unreachable in practice. Raise on ndim>1 so an upstream shape change surfaces as a loud error frame instead of silent noise. (debpalash review on #1328)
* fix(engines): include import error in pockettts is_available message
CodeRabbit Minor on #1328: the exception was caught as 'e' but never shown.
* fix(engines): lock _send to prevent concurrent-write framing corruption
Greptile P1 on #1328: the cold-load heartbeat thread and the main loop both call _send (stdout write). The stop+join serializes the normal case, but a join timeout leaves a window where both threads write length+body segments concurrently, interleaving the wire framing. Add a threading.Lock around the write so concurrent _send calls are serialized regardless.
* test(engines): cover the PocketTTS sidecar's silent failure modes
The four review findings fixed on this PR are all silent by construction:
an unsupported language rendered fluent, confident, wrong audio; the
channels-last downmix produced noise; interleaved frames desynchronized
the pipe permanently; a re-recorded clip kept serving the old voice. None
of them raise, and none would be caught by an end-to-end smoke test that
only asserts audio came back.
49 tests over the sidecar's pure logic — language selection, PCM
conversion, wire framing, the LRU voice cache — plus the backend surface
(recv-timeout guards, CPU-only declaration, sample-rate lockstep with the
sidecar, lazy registration). The model is mocked and the sidecar is
stdlib-only at import time, so none of it needs the optional pocket-tts
wheel or a child process.
Verified fail-before/pass-after by reverting the lock and the multi-channel
guard: the framing test fails with a length header decoded from inside
another frame's body, which is the corruption itself rather than a proxy
for it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: debpalash <nizam4103@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(appimage): let the host GStreamer win, and stop sharing its registry (#1333)
Recording from the AppImage failed with "No microphone found" on a Debian
13 host whose audio stack the reporter verified healthy (pactl, wpctl,
gst-launch with both pulsesrc and pipewiresrc), while the same build`s raw
binary recorded fine. GST_DEBUG=2 named it:
WARN GST_REGISTRY gst_registry_binary_check_magic:
Binary registry magic version is different : 1.23.90 != 1.3.0
GStreamer element appsink not found. Please install it.
linuxdeploy bundles libgstreamer-1.0 because WebKit links it, but not the
plugins: those are dlopen`d, so nothing static can see them to copy. The
bundled core falls back to the host plugin directory, whose plugins were
built against the host core, the version check rejects them, and the scan
yields nothing. appsink is one of the casualties and it is the element
WebKit hands a capture stream to, so getUserMedia() rejects NotFoundError.
Same class as #1258 (frozen bundled library against a host that moved on)
in a different library, which is why OMNIVOICE_PREFER_SYSTEM_WEBKIT=1 did
nothing for the reporter. Since we ship no plugins, the host core is the
only one that can agree with the plugins that will load — so prefer it,
with OMNIVOICE_PREFER_SYSTEM_GSTREAMER=0 as the escape hatch.
Also isolate the registry cache. GStreamer keys ~/.cache/gstreamer-1.0/
registry.<arch>.bin by architecture alone, so two cores of different
versions clobber each other`s file: that makes the failure depend on which
app ran last, and the AppImage corrupts the cache for every other
GStreamer app on the machine. Both directions go away with a private path.
AppRun.test.sh covers host-present, host-absent and opt-out; all three
fail against the previous AppRun.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(appimage): cover the ldconfig discovery path; docs fixes
CodeRabbit, all three valid:
- every GStreamer case forced ldconfig to fail, so the runtime-only-host
fallback (no -dev package, hence no .pc file) was never exercised. The
cases now select their discovery path, and the new ldconfig one fails if
that branch is removed.
- MD040: the GST_DEBUG fence had no language tag.
- the registry cache path follows XDG_CACHE_HOME when set; ~/.cache is only
the default. Documented, along with WHY the shared file is a problem.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(appimage): compose LD_LIBRARY_PATH once; host WebKit stays first
CI caught a real regression, not a flaky test. The GStreamer block prepended
its own directory, which put it AHEAD of the host WebKit dir — and "host
WebKit first" is the invariant #1258 turns on. On a host where the two
libraries live in different directories that silently changes which WebKit
resolves.
It only showed on Linux because the WebKit ldpath cases do not stub away a
real host GStreamer, so the runner had one to find and macOS did not.
Reproduced locally with an ldconfig shim, and confirmed the ordering is what
fixes it: with the old order the suite is 19/2, with this one 21/0.
Both decisions now compose one path in one place — host WebKit, host
GStreamer, bundle, inherited — so neither preference is weakened and the
ordering is stated where it is applied rather than implied by two
independent prepends. Same directory for both (the common case) is not
listed twice.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(appimage): preload the host GStreamer instead of hoisting its libdir
greptile P1, valid. The host GStreamer lives in a general system library
directory (/usr/lib/x86_64-linux-gnu on Debian), so putting that directory
ahead of ${HERE}/usr/lib replaced EVERY other bundled library with the
host copy — loader symbol errors, startup crashes, or a blank window on a
distro we never built against. One library needs to come from the host and
the mechanism has to be that narrow.
LD_PRELOAD names exactly that library and leaves the search path alone, so
the WebKit ordering from #1258 is untouched too (and this removes the
composed-LD_LIBRARY_PATH block that only existed to keep the two
prepends from fighting). The preload is inherited by the Python backend,
where nothing links GStreamer and it is inert — the accepted cost.
Tests now assert both halves: the library IS preloaded, and the libdir is
NOT hoisted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(appimage): verify the host GStreamer loads before preloading it
greptile P1, valid. The host core links GLib and the bundle ships GLib too,
resolved bundle-first — so a host GStreamer built against newer GLib than
we bundle fails its relocations and the app does not start at all. That is
strictly worse than the broken microphone this PR fixes. Taking host GLib
as well is not an option either: GLib is what WebKit is built against, so
pulling it from the host reopens #961/#1258.
Rather than predict the pairing, test it. The loader processes LD_PRELOAD
for any binary, so running `true` under the exact environment the app will
get is a complete check of whether the library loads there — a missing
dependency or an unresolved version tag ("version GLIB_2.84 not found")
fails it and nothing else runs. On failure the preload is skipped, the app
starts on the bundled core, and a warning names the mismatch so the user
has a thread to pull rather than a silent half-fix.
OMNIVOICE_APPRUN_PRELOAD_PROBE lets the suite choose the outcome, matching
the existing OMNIVOICE_APPRUN_WK_MARKER precedent; the new case fails if
the guard is removed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(macos): declare the floor the app actually delivers (13.3, not 12)
The app declared minimumSystemVersion 12.0 and the docs promised Monterey,
while the frontend required Safari 16.4 in three independent places: Vite's
default build target (baseline-widely-available = safari16.4), Tailwind v4's own
documented floor, and `@property` throughout its generated utilities. On
Monterey's WKWebView 15.6 the focus ring and accent surfaces resolve invalid,
and a bundled dependency ships a RegExp lookbehind that is a PARSE-time
SyntaxError no polyfill can reach.
Option B — actually supporting 15.6 — means setting build.target back,
replacing 64 color-mix() calls, dropping Tailwind v4 and replacing that
dependency, indefinitely, for an OS that stopped receiving security updates in
late 2024. The council was unanimous on A, and the precedent is uniform (Chrome
117, Electron 27, VS Code, Firefox 116).
minimumSystemVersion is also the guard: macOS itself refuses to launch a bundle
below it, so a Monterey user gets an explicit OS refusal rather than an app that
opens to a blank window — which matters because the Tauri updater has no
per-OS gating of its own.
Docs updated in the same change (README support table, docs/install/macos.md)
and the webCompat floor assertion re-derived to 16.4, so the post-floor API
list must be revisited the next time the floor moves.
Closes#1268
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(macos): raise the floor in the macOS overlay too, and assert it
Greptile P1, and correct: Tauri merges tauri.macos.conf.json OVER the base
config for a macOS build, and that file carried its own
minimumSystemVersion: 12.0. Changing the base config alone decided nothing —
the shipped bundle would have stayed Monterey-installable while the base
config, the README and the install docs all said 13.3.
Worse, the guard I added read only the base config, so it would have gone on
passing. A test that validates the wrong file is not a guard; it now asserts
both, with a comment saying why the overlay is the one that ships.
Also per review: the changelog entry was an editorial paragraph rather than a
one-line entry, and the section was missing ### Docs. Both fixed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(webcompat): the module header still described the old 12.0 floor
The floor moved to 13.3/Safari 16.4 in this PR and the test was re-derived,
but webCompat.js still told the next reader the oldest supported WebView was
15.6 — which would make every fill here look mandatory instead of retained
for Linux's unpinnable WebKitGTK (CodeRabbit).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The project carries 14 TTS + 11 ASR engines across 4 platforms with one
maintainer. That breadth is an asset only while every one of them still works;
otherwise it is a pile of support queues, and the first-run promise is what pays
for it.
So: engines are hired for a named job, not added to a list. Documents the job
map (each job has one holder), the seven conditions, the deprecation rule for
engines that lose their steward and their smoke test, and the out-of-tree path.
The point is to make "no" a property of the bar rather than a judgement of the
contributor — and to make "yes" fast when a proposal clears it. #1306 is the
first proposal judged against it.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(linux): AppImage blank window on Mesa 26.1+ hosts (#1258, #1244)
The AppImage bundles an Ubuntu-built WebKitGTK but ships no libEGL, so that
bundled WebKit runs against the HOST's Mesa. On Mesa >= 26.1 it calls
eglGetPlatformDisplay() in a way the newer driver rejects and the app dies
before it paints:
Could not create default EGL display: EGL_BAD_PARAMETER. Aborting...
No environment variable helps, because the failure is in EGL display creation
— before WebKit consults any rendering-path flag. #1258 confirmed
WEBKIT_DISABLE_DMABUF_RENDERER, WEBKIT_DMABUF_RENDERER_FORCE_SHM,
WEBKIT_SKIA_ENABLE_CPU_RENDERING, EGL_PLATFORM=surfaceless and
MESA_LOADER_DRIVER_OVERRIDE=swrast all fail identically.
Chasing the build runner's WebKit (#961 bumped 22.04 -> 24.04) cannot fix this
class: what we bundle is frozen and host Mesa keeps moving. So when the host
has a WebKitGTK at least as new as ours, let it win — the bundle still fills
every gap, and a host without WebKitGTK is untouched. That is exactly why
building from source works on the hardware where the AppImage does not.
The compositing workaround is re-decided against whichever library ends up
running, and AppRun.test.sh — which had never been wired into CI — now runs
there, so this logic stops being a regression test nothing executes.
* fix(review): the ordering change was a no-op; name the host libdir explicitly
CodeRabbit Major — correct, and it made the whole fix inert. LD_LIBRARY_PATH is
searched AHEAD of the linker's default paths no matter where in that variable a
directory sits, so on a normal launch (empty LD_LIBRARY_PATH) the bundle
remained the only explicit search directory and still won. Merely appending it
changed nothing. The host's WebKit libdir is now named explicitly, ahead of
ours. The new tests fail 3/3 against the previous version.
Greptile P1 — a host with the runtime but no -dev package has no .pc file, so
pkg-config can't answer and the check rejected a perfectly good system WebKit.
The libdir probe now falls back to ldconfig, and OMNIVOICE_PREFER_SYSTEM_WEBKIT
gives those users an explicit opt-in (=0 opts out) rather than gambling on an
unverified version, which would risk the #961 regression.
CodeRabbit — my changelog script had also inserted the CI entry into the
published 0.4.0 section. Removed; it belongs only under Unreleased.
CodeRabbit — the docs' source-build fallback used 'cd frontend', not the
repo-root flow the rest of the page documents. Fixed.
* fix(engines): warn before a long CPU synth burns the whole budget
#1288 closed the under-provisioned-GPU gap but left the CPU one open, and I
missed it: a CPU-only host is a BENIGN routing verdict, so routingNotice()
correctly stays silent — yet #1299 and #1260 are exactly that shape, CPU hosts
that hit the 300s budget on long text with no warning at all. "Nothing is
misconfigured" and "this will finish in time" are different claims.
Threshold is the backend's own definition of past-short: generate_timeout_for()
gives the first 1200 characters the flat budget before extending it, so
ordinary sentences on a CPU laptop stay quiet and only the shape that actually
times out is flagged. Hardware caveats still take precedence — one toast, and
it names the real reason rather than generic advice.
5 tests; engines.cpuLongText translated in all 21 locales.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(engines): don't tell CPU-tuned engines to switch to themselves
Greptile P1. The advice names OmniVoice GGUF and Supertonic-3 as the CPU-tuned
alternatives — shown to someone already running one of them, it is advice to
switch to what they are using. Those two now get the same warning without the
self-referential clause; the engine set matches the backend's own timeout
message so the two can't disagree about who is CPU-tuned.
Also documents the preflight in docs/performance.md (docs-sync rule): both
warning shapes, why the threshold is 1200 characters (it is the figure the
budget itself uses), that they are advisory and once-per-engine-per-session,
and the CPU-tuned exception.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(engines): add omnivoice-subprocess, a crash-isolated TTS engine
The default in-process OmniVoice engine runs on the GPU ThreadPoolExecutor.
When a generate or load exceeds its execution budget the pool is "reset", but
the abandoned worker thread cannot be killed (Python cannot interrupt a native
torch/MPS call), so it keeps holding the device until it finishes on its own
and later synths queue behind it and hang. The reset restores pool capacity
but not the device. This is the residual root cause behind the closed#730
and #1190: the messaging/reset mitigations address the symptom, not the
device-holding zombie.
Add an opt-in `omnivoice-subprocess` engine that runs the same model in a
child process via SubprocessBackend. A child process can be hard-killed: on a
recv-timeout the watchdog calls proc.kill(), reclaiming VRAM/device, and the
next request transparently respawns a fresh sidecar. The in-process engine
remains the default, so existing users see no change; this is an opt-in for
unattended / scheduled / reaction-triggered synthesis where a stuck job must
self-recover instead of hanging until a manual restart.
Base-class and mitigation changes that ship with it:
- SubprocessBackend.generate() now consumes non-terminal {"op":"progress"}
frames a sidecar emits during a cold load (previously the first cold
generate after spawn failed, then worked on retry). Additive: engines that
reply with audio directly are unaffected.
- recv_timeout_s is overridable per engine (default 60s unchanged); the new
engine sets it to the generate budget so a long-but-valid synth is not
falsely killed while a wedged one still is.
- make_room_before_generate(): free idle GPU memory before a warm, heavy
generate. The cold-load path already evicted; the warm path skipped it, so a
long synth on a VRAM-tight MPS box could contend its way into the budget.
Verified end-to-end against the live model (cold / warm / recovery-after-kill)
and under a sustained + concurrent-pressure soak: killed-worker recovery 5/5,
chunked long text 9/9, no memory leak.
* Address review: install_hint + move make_room into get_model
- Add `omnivoice-subprocess` to `_INSTALL_HINTS`; the
test_install_hints_cover_all_registered_backends gate requires every
registered backend to carry one (this was the CI failure).
- Move the warm-generate VRAM eviction out of the /generate and
/v1/audio/speech routes and into get_model()'s warm-return path, so EVERY
native TTS generate is covered (REST, WS TTS, dub, batch, audiobook), not
just the two REST routes. Drops the now-redundant per-route wiring.
(Greptile P1: the per-route placement missed the other generation surfaces.)
* Address review: drop dead long-text eviction path; log probe failure
- _should_make_room_for_generate: the long-text headroom boost became dead
code once the eviction moved into get_model() (which has no text), so the
long-text branch never fired. Removed the text param, the long-text
threshold/multiplier branch, and the now-unused _env_float helper. The core
RAM-tight gate (the part that matters on a starved box) is unchanged.
- Log the available_memory probe failure at debug instead of silently
swallowing it (CodeRabbit: silent swallow breaks the debug trail).
- Tests updated for the text-agnostic policy.
* fix(engines): stop subprocess generate() self-deadlock on 1-worker pools
SubprocessBackend.generate() acquires a GPU-pool slot for accounting, but
/v1/audio/speech and /generate dispatch backend.generate() via
run_on_gpu_pool_guarded, i.e. already ON a pool worker. On a 1-worker pool
(MPS) the inner pool.submit queued behind the very job running it and
slot_future.result(timeout=10) raised before the sidecar ever spawned, so
omnivoice-subprocess (and every other subprocess engine on MPS) surfaced the
in-process 300s-abandon instead of synthesizing.
Skip the slot acquisition when current_thread() is already a gpu-pool worker;
the outer guard already accounts for the slot. Direct callers (off the pool)
still acquire one. Regression test added (generate on a pool worker).
* Address review: reword slot-skip comment (fixes watermark-coverage CI) + simplify
- The slot-skip comment said "dispatch backend.generate() via", and
test_watermark_route_coverage's _SYNTH_CALL regex matches the literal
backend.generate( anywhere in a module, so it counted subprocess_backend.py
as a synthesis producer that must reference mark_synthetic (it doesn't — the
routes apply mark_synthetic; the engine sits below the chokepoint, like
tts_backend.py). Reworded to "dispatch generate() via".
- Fold in the simplify refinement: single negated predicate, import+pool
moved into the acquire branch.
* release: v0.4.1
Seven user-reported issues fixed since v0.4.0 (#1221–#1229). Version bumped
across the single source of truth (frontend/package.json) and its three
toolchain mirrors; [Unreleased] renamed to the release section that
release.yml extracts verbatim as the GitHub Release body.
Docker tag examples in docs/install/docker.md and deploy/dockerhub-overview.md
updated to 0.4.1 (docs-sync rule).
* release: #1239 review — sync Cargo.lock to 0.4.1
Greptile: the manifest said 0.4.1 while Cargo.lock still recorded 0.4.0, so a
`cargo build --locked` (and the Tauri bundler's own locked build) would fail
on the mismatch. Regenerating locally updated it but it was never staged.
* release: re-sync [0.4.1] after the fix merges, date it 2026-07-27
Picks up everything merged since the section was first written: the first-run
wizard chrome (#1241), the MCP host allowlist (#1249), the macOS 12 startup
crash (#1245), and the six error-message fixes (#1247, #1251, #1254, #1256,
#1257, #1262).
Deliberately NOT included:
- the dub delete-resurrection fix (#1252, #1253) — split to #1270 after it
needed six rounds of correction, the last two finding that the fix did not
close the reported case and that its own bound reintroduced it;
- the Linux AppImage WebKit fix (#1258, #1244) — held on #1265 pending
confirmation on a Mesa 26.1 host, which nobody has run.
* feat(mcp): OMNIVOICE_MCP_ALLOWED_HOSTS env var for transport-security allowlist (#1249)
Agents running in Docker containers (or on other machines) connect via a
hostname like host.containers.internal, which the MCP SDK's DNS-rebinding
guard rejects with 421. Add OMNIVOICE_MCP_ALLOWED_HOSTS (comma-separated
host patterns) that extends both allowed_hosts and allowed_origins in
create_mcp_server(). Default empty → no behavior change.
Test: assert the env var extends the allowlist + origins. Docs: mcp.md
notes the env var for Docker/LAN agents.
* fix(changelog): move MCP_ALLOWED_HOSTS entry after Highlights per quiet style
* fix(mcp): add https:// origins for HTTPS reverse proxy clients (greptile P1)
* docs(mcp): add security note for remote agent connections (coderabbit)
Two P1s, both correct:
- Being IN the override map was treated as proof of compatibility. If the
wheel ships neither the native arch nor the remap target, setting
HSA_OVERRIDE_GFX_VERSION only changes WHICH kernel is missing — gfx1151 with
a gfx1030-only build was routed to the GPU and would fail at launch. Both
arch_unsupported() and _configure_rocm_if_needed() now require the target to
be present, and fall back to CPU otherwise.
- An EMPTY arch list means the build's metadata is unavailable, not that the
GPU is unsupported. The remap branch read that unknown state as a confirmed
mismatch and would push a natively-supported gfx1151 onto foreign gfx1100
kernels. Now fails open and changes nothing, matching the fail-open contract
the rest of the probe follows.
ROCM_GFX_OVERRIDES values are now the target gfx NAME rather than the HSA
version string, so the "is the target present?" check is a direct membership
test; hsa_override_for() derives the env-var form, covered by a test that
every entry in the map converts cleanly.
Also: CHANGELOG entries shortened with refs last, and the MD028 blank line
between the two docker.md blockquotes (CodeRabbit).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The GPU compatibility gate built a CUDA-namespace tag from
`get_device_capability()` (`sm_115` on a gfx1151 Strix Halo) and looked for it
in `torch.cuda.get_arch_list()` — which on a ROCm wheel returns gfx *names*
(`gfx1100`, `gfx1151`, …). The two namespaces can never intersect, so
`check_device_compatibility()` returned False on every ROCm build and
`get_best_device()` silently returned "cpu": torch saw the GPU,
`torch.cuda.is_available()` was True, and the app ran on the CPU anyway.
The comparison was copy-pasted in three places, all with the same bug, so it
now lives once in `core.device_caps.arch_unsupported()` and branches on the
build (gfx names on ROCm, sm_/compute_ tags on CUDA):
- `model_manager.check_device_compatibility` — the CPU force-route, plus a
ROCm-specific remedy instead of telling AMD users to install a cu128 wheel
- `device_caps._probe` — the kernel-risk note that downgraded the routing badge
- `engine_env._cuda_arch_supported_for_compile` — torch.compile off on all AMD
`_configure_rocm_if_needed` also applied `HSA_OVERRIDE_GFX_VERSION` from a
static map without checking whether the GPU needed it, remapping cards the
installed build supports natively onto foreign kernels. It now applies only
when the native GFX ID is genuinely absent from the arch list, and knows
gfx1150/gfx1151 (Strix Point/Halo).
Regression test: tests/test_rocm_arch_gate.py pins the reporter's host
resolving to "cuda", a genuine ROCm mismatch still being caught, the CUDA
path (#756 Blackwell fallback) unchanged, and the narrowed override.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the text-heavy 8-card grid with real app imagery: the three flagship
features (Voice Cloning, Voice Design, Video Dubbing) now lead with tight
cropped screenshots of their actual UI (docs/features/*.png, uniform 2.8:1
tiles) over a one-line label; the five conceptual/no-screenshot features
(Audiobook, Stories, Dictation, 100% Local, MCP) compact to emoji + a single
line. Less text, more app. The "…and 12 more" fold is unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stable v* releases previously credited nobody: the Contributors avatar strip
was wired only for the preview channel (preview-notes job), so the release
page showed just GitHub's native widget — which lists only the externally
@-thanked PR authors, never the owner, and can't be ordered.
Add a `contributors-strip` job that appends one "## Contributors" strip to the
stable release, crediting every PR author for the tag including the owner,
ranked by merged-PR count (desc, ties by handle). It suppresses GitHub's
duplicate native widget by neutralising the inline "— thanks @user!" text
mentions in the RELEASE body only (the repo CHANGELOG keeps the @handles); the
strip's own @handles sit in HTML attributes, which GitHub does not count as
mentions, so avatars stay linked.
Appends via `gh release edit` on the existing release (never a second softprops
publish — that splits installers across two releases), runs once (no matrix
race), and is idempotent (strips any prior block + inline @thanks before
re-appending). Docs: RELEASING.md §5b release-body row updated.
The live v0.4.0 release has been corrected to match (single ranked strip,
native widget gone).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GitHub natively recognizes CONTRIBUTING.md, SECURITY.md, SUPPORT.md, and
CODE_OF_CONDUCT.md in .github/ (Contributing link, Security policy tab, and
the community profile all keep resolving), so relocate the four there and
drop four files from the repo root.
Reference fixes in the same commit (no broken links):
- README.md / README_CN.md → .github/CONTRIBUTING.md
- docs/migration/real-time-voice-cloning.md → ../../.github/SUPPORT.md
- SUPPORT→SECURITY link unchanged (both now in .github/, same dir)
- tests/test_issue_fixes.py Discord-link check repointed to .github/
CONTRIBUTING.md (a missing path would silently skip, dropping coverage)
- CLAUDE.md docs-sync rule paths updated to match
Kept at root by design: README/LICENSE/CHANGELOG/CLAUDE/AGENTS (required or
convention), SPONSORS.md (wired to absolute GitHub URLs in FUNDING.yml, the
sponsor issue template, and sponsors.js), LICENSE-NOTICE.md (pairs with
LICENSE), README_CN.md (README translation, 29 relative links).
Also ignore the local memxt agent-memory DB (memxt.db*) so it stops sitting
loose in the working tree.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>