From 6c7e0116c24c239b7bd2bf607ab75e038400105d Mon Sep 17 00:00:00 2001 From: Thijs Daniels Date: Mon, 21 Sep 2026 03:44:23 +0200 Subject: [PATCH 1/2] fix(backend): discover user audio session runtime dir for PipeWire support --- backend/src/decktation_backend.py | 40 +++++++++++++++++++++++++++++++ backend/src/wow_voice_chat.py | 31 ++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/backend/src/decktation_backend.py b/backend/src/decktation_backend.py index d019232..312d167 100644 --- a/backend/src/decktation_backend.py +++ b/backend/src/decktation_backend.py @@ -33,6 +33,46 @@ sys.path.insert(0, dependency_path) logger.info(f"Added dependency path: {dependency_path}") +# Decky plugins run under systemd as root, which lacks the desktop user's session +# environment. Discover and set the user's runtime directory so that PortAudio +# and PipeWire can connect to the user session audio daemon (allowing USB, Bluetooth, +# and dynamically selected default microphones to be captured). +def _setup_audio_environment(): + if os.environ.get("XDG_RUNTIME_DIR") and os.environ.get("XDG_RUNTIME_DIR") != "/run/user/0": + return os.environ.get("XDG_RUNTIME_DIR") + candidate_uids = [] + decky_user_home = getattr(decky, "DECKY_USER_HOME", None) or os.environ.get("DECKY_USER_HOME") + if decky_user_home and os.path.exists(decky_user_home): + try: + candidate_uids.append(str(os.stat(decky_user_home).st_uid)) + except Exception: + pass + if os.environ.get("SUDO_UID"): + candidate_uids.append(os.environ["SUDO_UID"]) + if os.path.exists("/run/user"): + try: + for entry in sorted(os.listdir("/run/user")): + if entry != "0" and entry.isdigit(): + candidate_uids.append(entry) + except Exception: + pass + candidate_uids.extend(["1000", "1001", "1002"]) + + seen = set() + for uid in candidate_uids: + if uid in seen: + continue + seen.add(uid) + run_dir = f"/run/user/{uid}" + if os.path.exists(os.path.join(run_dir, "pipewire-0")): + os.environ["XDG_RUNTIME_DIR"] = run_dir + os.environ["PIPEWIRE_RUNTIME_DIR"] = run_dir + os.environ["PULSE_SERVER"] = f"unix:{run_dir}/pulse/native" + logger.info(f"Configured audio runtime directory: {run_dir}") + return run_dir + +_setup_audio_environment() + # sounddevice normally searches only system library paths on Linux. Store # builds bundle PortAudio in bin/lib so the plugin works on clean SteamOS # installations without modifying the read-only operating system. diff --git a/backend/src/wow_voice_chat.py b/backend/src/wow_voice_chat.py index c99a9a4..003f4a2 100644 --- a/backend/src/wow_voice_chat.py +++ b/backend/src/wow_voice_chat.py @@ -12,6 +12,37 @@ import subprocess from pathlib import Path from faster_whisper import WhisperModel + + +def _setup_audio_environment(): + if os.environ.get("XDG_RUNTIME_DIR") and os.environ.get("XDG_RUNTIME_DIR") != "/run/user/0": + return os.environ.get("XDG_RUNTIME_DIR") + candidate_uids = [] + if os.environ.get("SUDO_UID"): + candidate_uids.append(os.environ["SUDO_UID"]) + if os.path.exists("/run/user"): + try: + for entry in sorted(os.listdir("/run/user")): + if entry != "0" and entry.isdigit(): + candidate_uids.append(entry) + except Exception: + pass + candidate_uids.extend(["1000", "1001", "1002"]) + + seen = set() + for uid in candidate_uids: + if uid in seen: + continue + seen.add(uid) + run_dir = f"/run/user/{uid}" + if os.path.exists(os.path.join(run_dir, "pipewire-0")): + os.environ["XDG_RUNTIME_DIR"] = run_dir + os.environ["PIPEWIRE_RUNTIME_DIR"] = run_dir + os.environ["PULSE_SERVER"] = f"unix:{run_dir}/pulse/native" + return run_dir + +_setup_audio_environment() + import sounddevice as sd import numpy as np import wave From ff7e9a67a328dff10b65d9bc6665fe7715b8d79a Mon Sep 17 00:00:00 2001 From: Thijs Daniels Date: Tue, 22 Sep 2026 22:41:55 +0200 Subject: [PATCH 2/2] fix(backend): reconnect sounddevice dynamically on recording start if PipeWire session became available post-boot --- backend/src/decktation_backend.py | 18 ++++++++++++++++++ backend/src/wow_voice_chat.py | 17 +++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/backend/src/decktation_backend.py b/backend/src/decktation_backend.py index 312d167..c78d419 100644 --- a/backend/src/decktation_backend.py +++ b/backend/src/decktation_backend.py @@ -71,6 +71,23 @@ def _setup_audio_environment(): logger.info(f"Configured audio runtime directory: {run_dir}") return run_dir + +def ensure_audio_environment(): + """Ensure XDG_RUNTIME_DIR points to a valid PipeWire session and sounddevice is connected.""" + current_run_dir = os.environ.get("XDG_RUNTIME_DIR") + pipewire_available = current_run_dir and os.path.exists(os.path.join(current_run_dir, "pipewire-0")) + if not pipewire_available: + new_dir = _setup_audio_environment() + if new_dir and "sounddevice" in sys.modules: + try: + import sounddevice as sd + sd._terminate() + sd._initialize() + logger.info(f"Reconnected sounddevice to PipeWire ({new_dir})") + except Exception as e: + logger.warning(f"Failed to reinitialize sounddevice: {e}") + + _setup_audio_environment() # sounddevice normally searches only system library paths on Linux. Store @@ -920,6 +937,7 @@ async def set_active_preset(self, game: str): async def start_recording(self): """Start recording audio""" try: + ensure_audio_environment() if Plugin.voice_service is None: logger.error("Voice service not initialized") return {"success": False, "error": "Service not initialized"} diff --git a/backend/src/wow_voice_chat.py b/backend/src/wow_voice_chat.py index 003f4a2..04a9269 100644 --- a/backend/src/wow_voice_chat.py +++ b/backend/src/wow_voice_chat.py @@ -41,6 +41,22 @@ def _setup_audio_environment(): os.environ["PULSE_SERVER"] = f"unix:{run_dir}/pulse/native" return run_dir + +def ensure_audio_environment(): + """Ensure XDG_RUNTIME_DIR points to a valid PipeWire session and sounddevice is connected.""" + current_run_dir = os.environ.get("XDG_RUNTIME_DIR") + pipewire_available = current_run_dir and os.path.exists(os.path.join(current_run_dir, "pipewire-0")) + if not pipewire_available: + new_dir = _setup_audio_environment() + if new_dir: + try: + import sounddevice as sd + sd._terminate() + sd._initialize() + except Exception: + pass + + _setup_audio_environment() import sounddevice as sd @@ -650,6 +666,7 @@ def run_continuous(self, duration=5, pause=1): def start_recording(self): """Start recording audio (for push-to-talk)""" + ensure_audio_environment() with self.recording_lock: if self.is_recording: return