diff --git a/CHANGELOG.md b/CHANGELOG.md index 551f09df..a522c5ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ The bundled TTS model package (`pyproject.toml`) is versioned independently. - Linux AppImage: `OMNIVOICE_PREFER_SYSTEM_WEBKIT=1` forces your own WebKitGTK for hosts where its version can't be read automatically (no `pkg-config`), and `=0` forces the bundled one (#1258) - Dubbing: the transcription overlay said "Transcribing with Whisper…" whatever ASR engine was actually running — it now names the stage, in all 21 languages — thanks @paoloantinori! (#1352) - Error messages no longer arrive with terminal colour codes spliced into the sentence (`download: ^[[0;31mERROR:^[[0m …`) — every surfaced failure is cleaned now, whichever tool produced it. (#1344) +- Linux AppImage: recording failed with "No microphone found" on hosts whose GStreamer is newer than the build runner's, even with a verified-healthy audio stack — your own GStreamer now takes precedence, and the plugin cache is app-private so it can neither be confused by nor corrupt the one other apps use — thanks @Kakuzen93! (#1333) ### Docs diff --git a/docs/install/linux.md b/docs/install/linux.md index c648952b..044d1355 100644 --- a/docs/install/linux.md +++ b/docs/install/linux.md @@ -180,6 +180,62 @@ Tracking issues: [#62](https://github.com/debpalash/OmniVoice-Studio/issues/62), [#961](https://github.com/debpalash/OmniVoice-Studio/issues/961), [#1258](https://github.com/debpalash/OmniVoice-Studio/issues/1258). +## AppImage: "No microphone found" while the raw binary records fine + +Recording from the AppImage fails with *"No microphone found. Connect or enable +a microphone and try again."* — but `pactl list short sources` shows your +microphones, `gst-launch-1.0 pulsesrc … ! fakesink` captures, and running +`frontend/src-tauri/target/debug/omnivoice-studio` directly records without +trouble. `GST_DEBUG=2` shows the real message: + +```text +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. +``` + +Same shape as the blank-window problem above, in a different library. The +AppImage bundles the GStreamer **core** (WebKitGTK links it) but not its +**plugins** — those are loaded dynamically at runtime, so the packaging step +cannot see them to copy. The bundled core then reads *your* plugin directory, +whose plugins were built against *your* core, the version check rejects them, +and the scan produces nothing. `appsink` is one of the elements that goes +missing, and it is the one WebKit needs to hand over a capture stream — so +`getUserMedia()` reports no device. Your raw binary works because it uses your +core with your plugins, which agree. + +**From v0.4.3 the launcher prefers your system's GStreamer**, which is the only +core that can match the plugins that will actually load. It does that by +preloading that one library (`LD_PRELOAD`) rather than by putting your system +library directory ahead of the bundle — your GStreamer shares that directory +with most of the system, so hoisting it would quietly replace every *other* +bundled library too. If your distro's GStreamer is itself broken and you would +rather fall back to the bundled core: + +```bash +OMNIVOICE_PREFER_SYSTEM_GSTREAMER=0 ./OmniVoice.Studio_*.AppImage +``` + +The launcher checks first that your GStreamer can actually load alongside the +libraries the AppImage bundles — a system core built against newer GLib than we +ship would fail to load and take the whole app down with it, which is worse than +a missing microphone. If that check fails it prints a warning, keeps the bundled +core, and the app still starts (with capture still broken); building from source +avoids the mismatch entirely. + +The AppImage also keeps its plugin-scan cache to itself, at +`~/.cache/OmniVoice/gstreamer-registry.bin`, rather than in the shared +`~/.cache/gstreamer-1.0/`. GStreamer names that shared file by architecture +alone, so two cores of different versions overwrite each other's — which both +makes this failure depend on whichever application ran last, and lets the +AppImage corrupt the cache every other GStreamer app on your machine reads. + +If you set `XDG_CACHE_HOME`, the path follows it +(`$XDG_CACHE_HOME/OmniVoice/gstreamer-registry.bin`); `~/.cache` is the default +when it is unset. + +Tracking issue: [#1333](https://github.com/debpalash/OmniVoice-Studio/issues/1333). + ## .deb ffprobe conflict diff --git a/frontend/src-tauri/appimage/AppRun b/frontend/src-tauri/appimage/AppRun index b7cd426e..74e9eba4 100755 --- a/frontend/src-tauri/appimage/AppRun +++ b/frontend/src-tauri/appimage/AppRun @@ -160,6 +160,128 @@ else # Standard AppImage env that Tauri's auto-generated AppRun would have set. export LD_LIBRARY_PATH="${HERE}/usr/lib:${LD_LIBRARY_PATH:-}" fi + +# ── GStreamer: the bundle has a core but no plugins (#1333) ──────────────── +# Microphone capture died on Debian 13 with "No microphone found", while the +# same build's raw binary recorded fine. GST_DEBUG=2 named the cause: +# +# 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 at runtime, so nothing static can see them to +# copy. The bundled core therefore falls back to its compile-time default +# plugin directory, which is the HOST's, and the host's plugins were built +# against the host's core. Version check fails, the scan yields nothing, and +# `appsink` — the element WebKit hands a capture stream to — does not exist. +# getUserMedia() then rejects with NotFoundError and the UI says there is no +# microphone. +# +# Same class as the WebKit problem above (a frozen bundled library paired +# against a host that moved on) and the same remedy: since we ship no plugins, +# the host's core is the only one that can agree with the plugins that will +# actually load, so let it win. This is why the raw binary works — it sets no +# LD_LIBRARY_PATH, so core and plugins are both the host's. +# +# "Let it win" here means preloading that ONE library, not hoisting the +# directory it sits in — see _system_gstreamer_lib below. + +#: Full path to the host's libgstreamer-1.0.so.0, or empty. +# +# The FILE, not its directory. The host's 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 would replace every OTHER bundled library +# with the host's copy too — which is how you get loader symbol errors or a +# blank window on a distro we never built against (greptile). One library needs +# to come from the host; the mechanism has to be that narrow. +_system_gstreamer_lib() { + local dir path + dir="$(pkg-config --variable=libdir gstreamer-1.0 2>/dev/null || echo "")" + if [ -n "$dir" ] && [ -e "$dir/libgstreamer-1.0.so.0" ]; then + printf '%s' "$dir/libgstreamer-1.0.so.0" + return 0 + fi + # Runtime-only host (no -dev package, so no .pc file), same fallback as + # _system_webkit_libdir. + if command -v ldconfig >/dev/null 2>&1; then + path="$(ldconfig -p 2>/dev/null \ + | awk '/libgstreamer-1\.0\.so\.0 /{print $NF; exit}')" + if [ -n "$path" ] && [ -e "$path" ]; then + printf '%s' "$path" + return 0 + fi + fi + return 1 +} + +_prefer_system_gstreamer() { + # Unlike WebKit there is no version comparison to make: we bundle no + # plugins, so a bundled core can only ever pair with host plugins it was not + # built against. Any host core is better than that. The override exists for + # a host whose GStreamer is genuinely broken, where falling back to the + # bundled core at least keeps the rest of the app running. + [ "${OMNIVOICE_PREFER_SYSTEM_GSTREAMER:-}" = "0" ] && return 1 + return 0 +} + +#: Can this library actually load in the environment the app will run in? +# +# The host's GStreamer links GLib, and the bundle ships GLib too — resolved +# bundle-first. A host core built against newer GLib than we bundle therefore +# fails its relocations and the app does not start AT ALL, which is a worse +# outcome than the broken microphone this is fixing (greptile). Pairing host +# GStreamer with host GLib is not an option either: GLib is what WebKit is +# built against, so pulling that from the host reopens #961/#1258. +# +# So do not predict the pairing — test it. The dynamic loader processes +# LD_PRELOAD for *any* binary, so running `true` under the exact environment +# the app will get is a complete check of "does this library load here": +# a missing dependency or an unresolved version tag (`version 'GLIB_2.84' not +# found`) fails it, and nothing else runs. Cheap, and decisive where a version +# comparison would be guesswork. +# OMNIVOICE_APPRUN_PRELOAD_PROBE lets the unit tests choose the outcome, the +# same way OMNIVOICE_APPRUN_WK_MARKER points at a fixture marker — the decision +# logic is what is testable here; whether a given .so loads is the OS's answer. +_preload_loads_cleanly() { + local probe + probe="${OMNIVOICE_APPRUN_PRELOAD_PROBE:-$(command -v true 2>/dev/null || echo /bin/true)}" + [ -x "$probe" ] || return 1 + LD_PRELOAD="$1" LD_LIBRARY_PATH="${LD_LIBRARY_PATH:-}" "$probe" 2>/dev/null +} + +# LD_PRELOAD, not LD_LIBRARY_PATH. Preloading names exactly one library and +# leaves the search path — and therefore every other bundled library — alone. +# Reordering directories cannot be that precise: the host's GStreamer shares a +# directory with most of the system, so hoisting it hoists everything. +# +# It is inherited by the Python backend we spawn, where nothing links GStreamer +# and the preload is inert. That is the accepted cost of the narrower mechanism. +if _prefer_system_gstreamer; then + _SYS_GST_LIB="$(_system_gstreamer_lib || echo "")" + if [ -n "$_SYS_GST_LIB" ] && _preload_loads_cleanly "$_SYS_GST_LIB"; then + export LD_PRELOAD="${_SYS_GST_LIB}${LD_PRELOAD:+ $LD_PRELOAD}" + elif [ -n "$_SYS_GST_LIB" ]; then + # Fail safe, and say so: the app still starts on the bundled core, which + # is where the microphone problem lives, so the user needs a thread to + # pull rather than a silent half-fix. + echo "OmniVoice: your GStreamer (${_SYS_GST_LIB}) cannot load against the" >&2 + echo " libraries this AppImage bundles, so it is not being used. Audio" >&2 + echo " capture may not find any microphone (see issue #1333). Building" >&2 + echo " from source avoids the mismatch entirely." >&2 + fi +fi + +# Private registry cache, unconditionally. GStreamer caches its plugin scan in +# ~/.cache/gstreamer-1.0/registry..bin, keyed only by architecture — so +# two cores of different versions clobber each other's file. That is a second, +# independent defect: it makes the failure above intermittent (it depends on +# which application ran last), and the AppImage corrupts the cache for every +# other GStreamer app on the machine. Giving this app its own file removes the +# interaction in both directions. +export GST_REGISTRY_1_0="${XDG_CACHE_HOME:-${HOME:-/tmp}/.cache}/OmniVoice/gstreamer-registry.bin" +mkdir -p -- "$(dirname -- "$GST_REGISTRY_1_0")" 2>/dev/null || true + export XDG_DATA_DIRS="${HERE}/usr/share:${XDG_DATA_DIRS:-/usr/local/share:/usr/share}" exec "${HERE}/usr/bin/omnivoice-studio" "$@" diff --git a/frontend/src-tauri/appimage/AppRun.test.sh b/frontend/src-tauri/appimage/AppRun.test.sh index 22c8b1f3..45a3e328 100755 --- a/frontend/src-tauri/appimage/AppRun.test.sh +++ b/frontend/src-tauri/appimage/AppRun.test.sh @@ -320,6 +320,107 @@ run_workaround_with_system "healthy host drops the workaround" "2.44.3" "2.52.5" # range: the workaround must be re-armed for the version that actually runs. run_workaround_with_system "broken host re-arms it" "2.44.3" "2.46.1" "1" +# ── GStreamer: host core must win, and the registry must be private (#1333) ── +# The bundle ships libgstreamer-1.0 (WebKit links it) but no plugins (they are +# dlopen'd, so nothing static can see them to copy). A bundled core paired with +# host plugins fails its version check, finds no `appsink`, and getUserMedia() +# rejects with NotFoundError — the "No microphone found" the user saw, on a +# machine whose audio stack was verified healthy with pactl/wpctl/gst-launch. +# `discovery` selects which lookup path finds the host library: +# pkgconfig — the -dev package is installed and answers +# ldconfig — runtime-only host (no .pc file), the #1258-review fallback +# none — no host GStreamer at all +run_gst_case() { + local label="$1" optout="$2" discovery="$3" expected="$4" loadable="${5:-yes}" + local gstlibdir cachedir probe actual + gstlibdir="$(mktemp -d)" + cachedir="$(mktemp -d)" + [ "$discovery" = "none" ] || touch "$gstlibdir/libgstreamer-1.0.so.0" + # The load probe: AppRun runs this under the candidate LD_PRELOAD, so its + # exit status stands in for the dynamic loader accepting or rejecting the + # pairing. (A fixture .so is an empty file; only the real loader could + # answer that for real, and that is the OS's job, not this suite's.) + probe="$(mktemp)" + if [ "$loadable" = "yes" ]; then printf '#!/bin/sh\nexit 0\n' > "$probe" + else printf '#!/bin/sh\nexit 1\n' > "$probe"; fi + chmod +x "$probe" + + actual=$( + bash -c ' + set +e + export OMNIVOICE_PREFER_SYSTEM_GSTREAMER="'"$optout"'" + export OMNIVOICE_APPRUN_PRELOAD_PROBE="'"$probe"'" + export XDG_CACHE_HOME="'"$cachedir"'" + gstlibdir="'"$gstlibdir"'" + discovery="'"$discovery"'" + + pkg-config() { + if [ "$1" = "--variable=libdir" ] && [ "$2" = "gstreamer-1.0" ] \ + && [ "$discovery" = "pkgconfig" ]; then + echo "$gstlibdir"; return 0 + fi + return 1 + } + export -f pkg-config + # No host WebKit either way; this case is only about the GStreamer branch. + ldconfig() { + [ "$discovery" = "ldconfig" ] || return 1 + echo " libgstreamer-1.0.so.0 (libc6,x86-64) => $gstlibdir/libgstreamer-1.0.so.0" + } + export -f ldconfig + exec() { :; } + export -f exec + + unset LD_LIBRARY_PATH + unset LD_PRELOAD + # shellcheck disable=SC1090 + source "'"$THIS_DIR"'/AppRun" >/dev/null 2>&1 || true + case "${LD_PRELOAD:-}" in + *"$gstlibdir/libgstreamer-1.0.so.0"*) printf "gst-preloaded" ;; + *) printf "no-gst" ;; + esac + # The whole point of preloading is that the SEARCH PATH is untouched: + # the host GStreamer sits in a general system libdir, so hoisting that + # directory would supersede every other bundled library too (greptile). + case "$LD_LIBRARY_PATH" in + "$gstlibdir":*) printf "+libdir-hoisted" ;; + *) printf "+libdir-intact" ;; + esac + case "${GST_REGISTRY_1_0:-}" in + "'"$cachedir"'"/OmniVoice/*) printf "+private-registry" ;; + *) printf "+shared-registry" ;; + esac' + ) + rm -rf "$gstlibdir" "$cachedir" "$probe" + + if [[ "$actual" == "$expected" ]]; then + echo "PASS [$label]" + PASS_COUNT=$((PASS_COUNT + 1)) + else + echo "FAIL [$label]: expected '$expected' got '$actual'" >&2 + FAIL_COUNT=$((FAIL_COUNT + 1)) + fi +} + +# The reported machine: a healthy host GStreamer exists, so it must resolve +# ahead of the bundled core. +run_gst_case "host GStreamer wins (pkg-config)" "" "pkgconfig" "gst-preloaded+libdir-intact+private-registry" +# Runtime-only host: the library is installed but there is no .pc file, so only +# ldconfig can find it. Unlike WebKit there is no version to compare, so this +# path must still win rather than fall back (CodeRabbit: the fallback branch was +# untested because every case forced ldconfig to fail). +run_gst_case "host GStreamer wins (ldconfig)" "" "ldconfig" "gst-preloaded+libdir-intact+private-registry" +# A host with no GStreamer at all: nothing to prefer, and the bundled core is +# all there is. Must not break, and must still get a private registry. +run_gst_case "no host GStreamer is harmless" "" "none" "no-gst+libdir-intact+private-registry" +# Escape hatch for a host whose own GStreamer is broken. +run_gst_case "opt-out keeps the bundled core" "0" "pkgconfig" "no-gst+libdir-intact+private-registry" +# A host core that needs newer GLib than the bundle ships fails its relocations +# and the app would not start AT ALL — worse than the broken microphone this +# fixes (greptile). The load probe catches that, so the preload is skipped and +# the app still launches on the bundled core. +run_gst_case "unloadable host core is skipped" "" "pkgconfig" "no-gst+libdir-intact+private-registry" "no" + echo echo "─── AppRun test summary: $PASS_COUNT pass / $FAIL_COUNT fail ───" if [[ $FAIL_COUNT -ne 0 ]]; then