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
This commit is contained in:
2026-04-02 07:51:42 +02:00
parent 251f9c238d
commit 04b655e664
2 changed files with 43 additions and 22 deletions
+42 -21
View File
@@ -217,27 +217,43 @@ async def open_file(body: dict, user: dict = Depends(current_user)):
return {"ok": True} 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") @router.get("/audio/devices")
async def list_audio_devices(user: dict = Depends(current_user)): async def list_audio_devices(user: dict = Depends(current_user)):
import subprocess import sounddevice as sd
if not user.get("is_admin"): if not user.get("is_admin"):
raise HTTPException(status_code=403, detail="Nur Administratoren") raise HTTPException(status_code=403, detail="Nur Administratoren")
try: try:
out = subprocess.check_output( devices = [
["pactl", "list", "sources", "short"], {"index": i, "name": d["name"]}
stderr=subprocess.DEVNULL, timeout=5, for i, d in enumerate(sd.query_devices())
).decode() if d["max_input_channels"] > 0
]
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"pactl fehlgeschlagen: {e}") raise HTTPException(status_code=500, detail=f"sounddevice 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 "",
})
return devices return devices
@@ -246,21 +262,25 @@ async def create_combined_source(body: dict, user: dict = Depends(current_user))
import subprocess, json, pathlib import subprocess, json, pathlib
if not user.get("is_admin"): if not user.get("is_admin"):
raise HTTPException(status_code=403, detail="Nur Administratoren") raise HTTPException(status_code=403, detail="Nur Administratoren")
mic = body.get("mic", "") mic_sd = body.get("mic", "")
monitor = body.get("monitor", "") monitor_sd = body.get("monitor", "")
if not mic or not monitor: if not mic_sd or not monitor_sd:
raise HTTPException(status_code=400, detail="mic und monitor erforderlich") 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( out = subprocess.check_output(
["pactl", "list", "sources", "short"], stderr=subprocess.DEVNULL, timeout=5 ["pactl", "list", "sources", "short"], stderr=subprocess.DEVNULL, timeout=5
).decode() ).decode()
known = {line.split("\t")[1] for line in out.strip().splitlines() if "\t" in line} 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: if mic not in known or monitor not in known:
raise HTTPException(status_code=400, detail="Unbekanntes Audio-Device") raise HTTPException(status_code=400, detail="Unbekanntes Audio-Device")
# Use description without spaces so sounddevice name == sink_name
sink_id = subprocess.check_output([ sink_id = subprocess.check_output([
"pactl", "load-module", "module-null-sink", "pactl", "load-module", "module-null-sink",
"sink_name=transkriptor-combined", "sink_name=transkriptor-combined",
"sink_properties=device.description=Transkriptor Combined", "sink_properties=device.description=transkriptor-combined",
], timeout=5).decode().strip() ], timeout=5).decode().strip()
mic_id = subprocess.check_output([ mic_id = subprocess.check_output([
"pactl", "load-module", "module-loopback", "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) state_path.parent.mkdir(parents=True, exist_ok=True)
ids = [int(sink_id), int(mic_id), int(mon_id)] 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})) 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") @router.get("/settings")
+1 -1
View File
@@ -71,7 +71,7 @@ def _restore_pipewire_combined():
sink_id = subprocess.check_output([ sink_id = subprocess.check_output([
"pactl", "load-module", "module-null-sink", "pactl", "load-module", "module-null-sink",
"sink_name=transkriptor-combined", "sink_name=transkriptor-combined",
"sink_properties=device.description=Transkriptor Combined", "sink_properties=device.description=transkriptor-combined",
], timeout=5).decode().strip() ], timeout=5).decode().strip()
mic_id = subprocess.check_output([ mic_id = subprocess.check_output([
"pactl", "load-module", "module-loopback", "pactl", "load-module", "module-loopback",