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