PySide6/PJSUA2-basiertes Desktop-Softphone mit: - SIP-Telefonie (Anrufe, DTMF, Halten, Transfer, Konferenz) - CardDAV-Kontaktsync mit Write-Back (Multi-Account) - Kontaktverwaltung mit Suche und Bearbeiten-Dialog - Anrufliste mit Namensauflösung - Favoriten-Panel (manuell + häufig angerufen) - BLF-Panel (SIP Presence Monitoring) - Responsives Layout (kompakt/erweitert) - Click-to-Call (tel:/sip: URI via D-Bus) - KDE-Integration (Tray, Benachrichtigungen) - PipeWire/PulseAudio Audio-Routing - AppImage Build-Support (PyInstaller + appimagetool) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
259 lines
8.6 KiB
Python
259 lines
8.6 KiB
Python
"""Audio-Manager - Audiogeräte auflisten, wechseln und Lautstärke steuern.
|
|
|
|
Nutzt PipeWire/PulseAudio-Namen (wie KDE) statt kryptischer ALSA-Bezeichnungen.
|
|
PJSUA2 wird auf das 'pulse'-Device gesetzt, damit PipeWire das Routing übernimmt.
|
|
"""
|
|
|
|
import subprocess
|
|
import pjsua2 as pj
|
|
|
|
|
|
def _pipewire_geraete_holen():
|
|
"""PipeWire/PulseAudio-Geräte mit KDE-freundlichen Namen holen.
|
|
|
|
Returns:
|
|
(sinks, sources): Dicts mit {pw_name: description}
|
|
"""
|
|
sinks = {} # Wiedergabe
|
|
sources = {} # Aufnahme
|
|
|
|
try:
|
|
# Sinks (Wiedergabegeräte)
|
|
result = subprocess.run(
|
|
["pactl", "list", "sinks"],
|
|
capture_output=True, text=True, timeout=3,
|
|
)
|
|
aktueller_name = None
|
|
for zeile in result.stdout.splitlines():
|
|
zeile = zeile.strip()
|
|
if zeile.startswith("Name:"):
|
|
aktueller_name = zeile.split(":", 1)[1].strip()
|
|
elif zeile.startswith("Description:") and aktueller_name:
|
|
sinks[aktueller_name] = zeile.split(":", 1)[1].strip()
|
|
aktueller_name = None
|
|
|
|
# Sources (Aufnahmegeräte)
|
|
result = subprocess.run(
|
|
["pactl", "list", "sources"],
|
|
capture_output=True, text=True, timeout=3,
|
|
)
|
|
aktueller_name = None
|
|
for zeile in result.stdout.splitlines():
|
|
zeile = zeile.strip()
|
|
if zeile.startswith("Name:"):
|
|
aktueller_name = zeile.split(":", 1)[1].strip()
|
|
elif zeile.startswith("Description:") and aktueller_name:
|
|
# Monitor-Quellen ausfiltern (sind keine echten Mikrofone)
|
|
if ".monitor" not in aktueller_name:
|
|
sources[aktueller_name] = zeile.split(":", 1)[1].strip()
|
|
aktueller_name = None
|
|
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
pass
|
|
|
|
return sinks, sources
|
|
|
|
|
|
def _alsa_name_zu_pw_name(alsa_name):
|
|
"""Versucht einen ALSA-Gerätenamen auf PipeWire-Namen abzubilden.
|
|
|
|
PJSUA2 gibt z.B. 'Razer Nari: USB Audio (hw:0,0)' zurück.
|
|
PipeWire nutzt z.B. 'alsa_output.usb-Razer_Razer_Nari-00.mono-fallback'.
|
|
|
|
Matching über Schlüsselwörter im ALSA-Namen.
|
|
"""
|
|
# Extrahiere Schlüsselwörter aus dem ALSA-Namen (ohne hw:X,Y)
|
|
bereinigt = alsa_name.split("(")[0].strip().lower()
|
|
# Wörter extrahieren
|
|
woerter = [w for w in bereinigt.replace(":", " ").replace("-", " ").split()
|
|
if len(w) > 2]
|
|
return woerter
|
|
|
|
|
|
class AudioManager:
|
|
"""Verwaltet Audiogeräte über PipeWire/PulseAudio mit PJSUA2-Backend.
|
|
|
|
Zeigt PipeWire-Geräte mit KDE-freundlichen Namen an.
|
|
PJSUA2 nutzt das 'pulse'-ALSA-Plugin für PipeWire-Routing.
|
|
"""
|
|
|
|
def __init__(self, engine):
|
|
self._engine = engine
|
|
self._pulse_dev_id = None # PJSUA2-ID des 'pulse'-Geräts
|
|
|
|
@property
|
|
def _aud_mgr(self):
|
|
"""Zugriff auf den PJSUA2 AudDevManager."""
|
|
if self._engine.ep:
|
|
return self._engine.ep.audDevManager()
|
|
return None
|
|
|
|
def _pjsua2_geraete(self):
|
|
"""Rohe PJSUA2-Geräteliste holen."""
|
|
if not self._aud_mgr:
|
|
return []
|
|
geraete = []
|
|
try:
|
|
dev_list = self._aud_mgr.enumDev2()
|
|
for i, dev in enumerate(dev_list):
|
|
info = dev.info if hasattr(dev, "info") else dev
|
|
geraete.append({
|
|
"id": i,
|
|
"name": info.name,
|
|
"eingaenge": info.inputCount,
|
|
"ausgaenge": info.outputCount,
|
|
"driver": info.driver,
|
|
})
|
|
except (pj.Error, AttributeError):
|
|
pass
|
|
return geraete
|
|
|
|
def _pulse_device_finden(self):
|
|
"""Finde die PJSUA2-Device-ID für 'pulse' (PipeWire-Routing)."""
|
|
if self._pulse_dev_id is not None:
|
|
return self._pulse_dev_id
|
|
|
|
geraete = self._pjsua2_geraete()
|
|
for dev in geraete:
|
|
if dev["name"] == "pulse":
|
|
self._pulse_dev_id = dev["id"]
|
|
return self._pulse_dev_id
|
|
return None
|
|
|
|
def pulse_als_standard_setzen(self):
|
|
"""Setzt 'pulse' als Standard-Audiogerät (PipeWire-Routing).
|
|
|
|
Damit übernimmt PipeWire/KDE die Gerätezuordnung.
|
|
"""
|
|
pulse_id = self._pulse_device_finden()
|
|
if pulse_id is not None and self._aud_mgr:
|
|
try:
|
|
self._aud_mgr.setCaptureDev(pulse_id)
|
|
self._aud_mgr.setPlaybackDev(pulse_id)
|
|
return True
|
|
except pj.Error:
|
|
pass
|
|
return False
|
|
|
|
def aufnahme_geraete(self):
|
|
"""Aufnahmegeräte mit PipeWire-Beschreibung (wie KDE).
|
|
|
|
Returns:
|
|
Liste von Dicts: {pw_name, beschreibung, pjsua2_id}
|
|
"""
|
|
_, sources = _pipewire_geraete_holen()
|
|
pj_geraete = self._pjsua2_geraete()
|
|
|
|
geraete = []
|
|
for pw_name, beschreibung in sources.items():
|
|
# Versuche passendes PJSUA2-Device zu finden
|
|
pj_id = self._pw_zu_pjsua2(pw_name, pj_geraete, aufnahme=True)
|
|
geraete.append({
|
|
"id": pj_id,
|
|
"pw_name": pw_name,
|
|
"name": beschreibung,
|
|
"eingaenge": 1,
|
|
"ausgaenge": 0,
|
|
})
|
|
|
|
return geraete
|
|
|
|
def wiedergabe_geraete(self):
|
|
"""Wiedergabegeräte mit PipeWire-Beschreibung (wie KDE).
|
|
|
|
Returns:
|
|
Liste von Dicts: {pw_name, beschreibung, pjsua2_id}
|
|
"""
|
|
sinks, _ = _pipewire_geraete_holen()
|
|
pj_geraete = self._pjsua2_geraete()
|
|
|
|
geraete = []
|
|
for pw_name, beschreibung in sinks.items():
|
|
pj_id = self._pw_zu_pjsua2(pw_name, pj_geraete, aufnahme=False)
|
|
geraete.append({
|
|
"id": pj_id,
|
|
"pw_name": pw_name,
|
|
"name": beschreibung,
|
|
"eingaenge": 0,
|
|
"ausgaenge": 1,
|
|
})
|
|
|
|
return geraete
|
|
|
|
def _pw_zu_pjsua2(self, pw_name, pj_geraete, aufnahme=False):
|
|
"""Ordnet einen PipeWire-Namen einem PJSUA2-Device-Index zu.
|
|
|
|
Da PJSUA2 über das 'pulse'-Device läuft, wird die
|
|
PipeWire-Device-Auswahl über `pactl set-default-*` gesteuert.
|
|
Rückgabe: pw_name als Identifikator (kein PJSUA2-Index nötig).
|
|
"""
|
|
# Wir nutzen den PipeWire-Namen direkt als ID-String
|
|
# Die tatsächliche Umschaltung passiert über pactl
|
|
return pw_name
|
|
|
|
def aufnahme_geraet_setzen(self, pw_name):
|
|
"""Aufnahmegerät über PipeWire setzen.
|
|
|
|
Args:
|
|
pw_name: PipeWire-Device-Name (z.B. 'alsa_input.usb-...')
|
|
"""
|
|
if isinstance(pw_name, int):
|
|
# Fallback: Direkte PJSUA2-ID (Kompatibilität)
|
|
if self._aud_mgr:
|
|
try:
|
|
self._aud_mgr.setCaptureDev(pw_name)
|
|
return True
|
|
except pj.Error:
|
|
return False
|
|
return False
|
|
|
|
# PipeWire-Default-Source setzen
|
|
try:
|
|
subprocess.run(
|
|
["pactl", "set-default-source", pw_name],
|
|
capture_output=True, timeout=3,
|
|
)
|
|
return True
|
|
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
return False
|
|
|
|
def wiedergabe_geraet_setzen(self, pw_name):
|
|
"""Wiedergabegerät über PipeWire setzen.
|
|
|
|
Args:
|
|
pw_name: PipeWire-Device-Name (z.B. 'alsa_output.usb-...')
|
|
"""
|
|
if isinstance(pw_name, int):
|
|
# Fallback: Direkte PJSUA2-ID (Kompatibilität)
|
|
if self._aud_mgr:
|
|
try:
|
|
self._aud_mgr.setPlaybackDev(pw_name)
|
|
return True
|
|
except pj.Error:
|
|
return False
|
|
return False
|
|
|
|
# PipeWire-Default-Sink setzen
|
|
try:
|
|
subprocess.run(
|
|
["pactl", "set-default-sink", pw_name],
|
|
capture_output=True, timeout=3,
|
|
)
|
|
return True
|
|
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
return False
|
|
|
|
def mikrofon_lautstaerke(self, level):
|
|
"""Mikrofon-Lautstärke setzen (0.0 = stumm, 1.0 = normal, 2.0 = laut)."""
|
|
if self._aud_mgr:
|
|
try:
|
|
self._aud_mgr.getCaptureDevMedia().adjustTxLevel(level)
|
|
except pj.Error:
|
|
pass
|
|
|
|
def lautsprecher_lautstaerke(self, level):
|
|
"""Lautsprecher-Lautstärke setzen (0.0 = stumm, 1.0 = normal, 2.0 = laut)."""
|
|
if self._aud_mgr:
|
|
try:
|
|
self._aud_mgr.getPlaybackDevMedia().adjustRxLevel(level)
|
|
except pj.Error:
|
|
pass
|