From 04b655e664536574a9efdd9872431e848270396e Mon Sep 17 00:00:00 2001 From: "thomas.kopp" Date: Thu, 2 Apr 2026 07:51:42 +0200 Subject: [PATCH] fix: use sounddevice names for audio device list and combined source - /audio/devices now returns sounddevice device names (not pactl source names) so the stored device name works directly with sd.InputStream - /audio/combined maps sounddevice names back to pactl source names via description matching for the loopback commands - Combined sink description set to 'transkriptor-combined' (no spaces) so sounddevice name matches the value stored in config - Add _pactl_source_for_sd_name() helper for the mapping --- api/router.py | 63 ++++++++++++++++++++++++++++++++++----------------- main.py | 2 +- 2 files changed, 43 insertions(+), 22 deletions(-) diff --git a/api/router.py b/api/router.py index 7846245..7e911a4 100644 --- a/api/router.py +++ b/api/router.py @@ -217,27 +217,43 @@ async def open_file(body: dict, user: dict = Depends(current_user)): return {"ok": True} +def _pactl_source_for_sd_name(sd_name: str) -> str: + """Map a sounddevice device name to its pactl source name via description matching. + sounddevice strips the 'Monitor of ' prefix from pactl source descriptions. + Falls back to sd_name if no match found.""" + import subprocess + try: + out = subprocess.check_output( + ["pactl", "list", "sources"], stderr=subprocess.DEVNULL, timeout=5 + ).decode() + current_name = None + for line in out.splitlines(): + line = line.strip() + if line.startswith("Name:"): + current_name = line.split(":", 1)[1].strip() + elif line.startswith("Description:") and current_name: + desc = line.split(":", 1)[1].strip().removeprefix("Monitor of ") + if desc == sd_name: + return current_name + current_name = None + except Exception: + pass + return sd_name + + @router.get("/audio/devices") async def list_audio_devices(user: dict = Depends(current_user)): - import subprocess + import sounddevice as sd if not user.get("is_admin"): raise HTTPException(status_code=403, detail="Nur Administratoren") try: - out = subprocess.check_output( - ["pactl", "list", "sources", "short"], - stderr=subprocess.DEVNULL, timeout=5, - ).decode() + devices = [ + {"index": i, "name": d["name"]} + for i, d in enumerate(sd.query_devices()) + if d["max_input_channels"] > 0 + ] except Exception as e: - raise HTTPException(status_code=500, detail=f"pactl fehlgeschlagen: {e}") - devices = [] - for line in out.strip().splitlines(): - parts = line.split("\t") - if len(parts) >= 2: - devices.append({ - "index": parts[0], - "name": parts[1], - "state": parts[4] if len(parts) > 4 else "", - }) + raise HTTPException(status_code=500, detail=f"sounddevice fehlgeschlagen: {e}") return devices @@ -246,21 +262,25 @@ async def create_combined_source(body: dict, user: dict = Depends(current_user)) import subprocess, json, pathlib if not user.get("is_admin"): raise HTTPException(status_code=403, detail="Nur Administratoren") - mic = body.get("mic", "") - monitor = body.get("monitor", "") - if not mic or not monitor: + mic_sd = body.get("mic", "") + monitor_sd = body.get("monitor", "") + if not mic_sd or not monitor_sd: raise HTTPException(status_code=400, detail="mic und monitor erforderlich") - # Validate: names must come from pactl list — no shell injection via user input + # Map sounddevice names → pactl source names for loopback commands + mic = _pactl_source_for_sd_name(mic_sd) + monitor = _pactl_source_for_sd_name(monitor_sd) + # Validate pactl names exist out = subprocess.check_output( ["pactl", "list", "sources", "short"], stderr=subprocess.DEVNULL, timeout=5 ).decode() known = {line.split("\t")[1] for line in out.strip().splitlines() if "\t" in line} if mic not in known or monitor not in known: raise HTTPException(status_code=400, detail="Unbekanntes Audio-Device") + # Use description without spaces so sounddevice name == sink_name sink_id = subprocess.check_output([ "pactl", "load-module", "module-null-sink", "sink_name=transkriptor-combined", - "sink_properties=device.description=Transkriptor Combined", + "sink_properties=device.description=transkriptor-combined", ], timeout=5).decode().strip() mic_id = subprocess.check_output([ "pactl", "load-module", "module-loopback", @@ -275,8 +295,9 @@ async def create_combined_source(body: dict, user: dict = Depends(current_user)) ) state_path.parent.mkdir(parents=True, exist_ok=True) ids = [int(sink_id), int(mic_id), int(mon_id)] + # Store pactl names for restore, sounddevice name as device state_path.write_text(json.dumps({"ids": ids, "mic": mic, "monitor": monitor})) - return {"device": "transkriptor-combined.monitor", "module_ids": ids} + return {"device": "transkriptor-combined", "module_ids": ids} @router.get("/settings") diff --git a/main.py b/main.py index 7568225..819e85b 100644 --- a/main.py +++ b/main.py @@ -71,7 +71,7 @@ def _restore_pipewire_combined(): sink_id = subprocess.check_output([ "pactl", "load-module", "module-null-sink", "sink_name=transkriptor-combined", - "sink_properties=device.description=Transkriptor Combined", + "sink_properties=device.description=transkriptor-combined", ], timeout=5).decode().strip() mic_id = subprocess.check_output([ "pactl", "load-module", "module-loopback",