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:
+42
-21
@@ -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")
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user