pulse_combined.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347
  1. # -*- coding: utf-8 -*-
  2. """
  3. PulseAudio / PipeWire Combined-Sink Wrapper.
  4. Erlaubt es, mehrere Lautsprecher gleichzeitig zu bespielen
  5. indem ein virtueller Sink (`module-combine-sink`) erstellt
  6. wird, der alle ausgewaehlten echten Sinks bündelt.
  7. Funktioniert auf:
  8. - Linux mit PulseAudio
  9. - Linux mit PipeWire (pactl-API-kompatibel)
  10. - WSL2 mit WSLg (bringt Pulse mit)
  11. Funktioniert NICHT auf:
  12. - Linux ohne Pulse/PipeWire (z.B. Pi-OS-Lite headless)
  13. - Windows nativ
  14. - macOS
  15. In diesen Faellen meldet `available()` False und der
  16. Client faellt auf den klassischen Single-Output-Pfad zurueck.
  17. """
  18. from __future__ import annotations
  19. import asyncio
  20. import os
  21. import shutil
  22. from typing import NamedTuple
  23. from trixy_core.utils.debug import pinfo, pdebug, perror
  24. class PulseSink(NamedTuple):
  25. """Ein einzelner PulseAudio/PipeWire Sink."""
  26. index: int
  27. name: str # interner Name z.B. "alsa_output.usb-Generic-00.analog-stereo"
  28. description: str # Lesbarer Name z.B. "USB Audio CODEC Analog Stereo"
  29. sample_spec: str # z.B. "s16le 2ch 44100Hz"
  30. state: str # "RUNNING" | "IDLE" | "SUSPENDED"
  31. class PulseCombinedSink:
  32. """Verwaltet einen virtuellen Combined-Sink ueber pactl."""
  33. DEFAULT_NAME = "trixy_combined"
  34. def __init__(self, sink_name: str = DEFAULT_NAME) -> None:
  35. self._sink_name = sink_name
  36. self._module_index: int | None = None
  37. self._slaves: list[str] = []
  38. self._available: bool | None = None # Lazy-detect
  39. self._previous_default: str | None = None # Fuer Restore beim Teardown
  40. @property
  41. def sink_name(self) -> str:
  42. return self._sink_name
  43. @property
  44. def module_index(self) -> int | None:
  45. return self._module_index
  46. @property
  47. def slaves(self) -> list[str]:
  48. return list(self._slaves)
  49. async def available(self) -> bool:
  50. """True wenn pactl im PATH ist UND ein Pulse/PipeWire-Server antwortet."""
  51. if self._available is not None:
  52. return self._available
  53. if not shutil.which("pactl"):
  54. pdebug("pactl nicht gefunden — Combined-Sink-Support deaktiviert")
  55. self._available = False
  56. return False
  57. # Echten Server-Kontakt pruefen — pactl info schlaegt fehl wenn
  58. # kein Pulse/PipeWire-Daemon laeuft (z.B. ALSA-only).
  59. rc, _ = await self._run("info")
  60. self._available = rc == 0
  61. if not self._available:
  62. pdebug("pactl info fehlgeschlagen — kein Pulse/PipeWire-Server")
  63. return self._available
  64. # === Sink-Listing ===
  65. async def list_sinks(self) -> list[PulseSink]:
  66. """Listet alle realen Sinks (ohne den eigenen Combined-Sink)."""
  67. if not await self.available():
  68. return []
  69. rc, out = await self._run("list", "sinks")
  70. if rc != 0:
  71. return []
  72. return [s for s in self._parse_sinks(out) if s.name != self._sink_name]
  73. @staticmethod
  74. def _parse_sinks(text: str) -> list[PulseSink]:
  75. """Parst die Ausgabe von `pactl list sinks`."""
  76. sinks: list[PulseSink] = []
  77. current: dict = {}
  78. def flush() -> None:
  79. if not current:
  80. return
  81. try:
  82. sinks.append(PulseSink(
  83. index=int(current.get("index", -1)),
  84. name=current.get("name", ""),
  85. description=current.get("description", current.get("name", "")),
  86. sample_spec=current.get("sample_spec", ""),
  87. state=current.get("state", ""),
  88. ))
  89. except Exception:
  90. pass
  91. current.clear()
  92. for raw in text.splitlines():
  93. line = raw.rstrip()
  94. if line.startswith("Sink #"):
  95. flush()
  96. try:
  97. current["index"] = int(line.split("#", 1)[1])
  98. except Exception:
  99. pass
  100. continue
  101. stripped = line.lstrip()
  102. if stripped.startswith("Name:"):
  103. current["name"] = stripped.split(":", 1)[1].strip()
  104. elif stripped.startswith("Description:"):
  105. current["description"] = stripped.split(":", 1)[1].strip()
  106. elif stripped.startswith("Sample Specification:"):
  107. current["sample_spec"] = stripped.split(":", 1)[1].strip()
  108. elif stripped.startswith("State:"):
  109. current["state"] = stripped.split(":", 1)[1].strip()
  110. flush()
  111. return sinks
  112. # === Combined-Sink-Lifecycle ===
  113. async def find_existing_module(self) -> int | None:
  114. """Sucht ein bereits geladenes module-combine-sink mit unserem Namen."""
  115. rc, out = await self._run("list", "modules", "short")
  116. if rc != 0:
  117. return None
  118. for line in out.splitlines():
  119. parts = line.split("\t")
  120. if len(parts) < 2:
  121. continue
  122. try:
  123. idx = int(parts[0])
  124. except ValueError:
  125. continue
  126. module = parts[1]
  127. args = parts[2] if len(parts) >= 3 else ""
  128. if module == "module-combine-sink" and f"sink_name={self._sink_name}" in args:
  129. return idx
  130. return None
  131. async def setup(self, slaves: list[str]) -> bool:
  132. """Erstellt (oder ersetzt) den Combined-Sink mit den gewaehlten Slaves.
  133. Args:
  134. slaves: Liste der Sink-Namen die gebuendelt werden sollen.
  135. Returns:
  136. True bei Erfolg, False sonst.
  137. """
  138. if not await self.available():
  139. return False
  140. if not slaves:
  141. await self.teardown()
  142. return False
  143. if len(slaves) == 1:
  144. # Bei nur einem Sink macht ein Combined-Sink keinen Sinn —
  145. # Caller soll den Sink direkt nutzen.
  146. await self.teardown()
  147. return False
  148. # Bestehendes Modul vorher entladen
  149. await self.teardown()
  150. slaves_arg = ",".join(slaves)
  151. args = (
  152. "load-module",
  153. "module-combine-sink",
  154. f"sink_name={self._sink_name}",
  155. f"slaves={slaves_arg}",
  156. "sink_properties=device.description=Trixy_Multi_Speaker",
  157. )
  158. rc, out = await self._run(*args)
  159. if rc != 0:
  160. perror(f"Combined-Sink konnte nicht erstellt werden: {out.strip()}")
  161. return False
  162. # pactl gibt den Modul-Index als einzige Zahl zurueck
  163. try:
  164. self._module_index = int(out.strip().splitlines()[-1])
  165. except Exception:
  166. self._module_index = await self.find_existing_module()
  167. self._slaves = list(slaves)
  168. pinfo(
  169. f"Combined-Sink '{self._sink_name}' erstellt "
  170. f"(Modul #{self._module_index}, {len(slaves)} Slaves)"
  171. )
  172. return True
  173. async def teardown(self) -> None:
  174. """Entlaedt das Combined-Sink-Modul und stellt den alten Default wieder her."""
  175. if not await self.available():
  176. return
  177. # Vorherigen Default-Sink wiederherstellen BEVOR der Combined-Sink entladen wird
  178. await self.restore_default()
  179. idx = self._module_index
  180. if idx is None:
  181. idx = await self.find_existing_module()
  182. if idx is None:
  183. self._slaves = []
  184. return
  185. rc, _ = await self._run("unload-module", str(idx))
  186. if rc == 0:
  187. pdebug(f"Combined-Sink Modul #{idx} entladen")
  188. self._module_index = None
  189. self._slaves = []
  190. # === Default-Sink-Verwaltung ===
  191. #
  192. # Wir aendern den System-Default-Sink auf den Combined-Sink, damit
  193. # der Audio-Player (sounddevice ohne explizites device=) automatisch
  194. # in den Verbund streamt. Ohne diesen Schritt wuerde der Player
  195. # weiterhin auf einem einzelnen Sink landen.
  196. #
  197. # Beim Teardown wird der vorherige Default wiederhergestellt, damit
  198. # das System nach `trixy stop` nicht auf einem entladenen Sink haengt.
  199. async def get_default_sink(self) -> str:
  200. """Liest den aktuellen System-Default-Sink-Namen."""
  201. if not await self.available():
  202. return ""
  203. rc, out = await self._run("get-default-sink")
  204. if rc != 0:
  205. return ""
  206. return out.strip().splitlines()[0] if out.strip() else ""
  207. async def set_default_sink(self, sink_name: str) -> bool:
  208. """Setzt den System-Default-Sink."""
  209. if not await self.available():
  210. return False
  211. rc, _ = await self._run("set-default-sink", sink_name)
  212. return rc == 0
  213. async def make_default(self) -> bool:
  214. """Macht den Combined-Sink zum System-Default und merkt sich den vorherigen."""
  215. if self._module_index is None:
  216. return False
  217. current = await self.get_default_sink()
  218. # Nur merken wenn es nicht schon der Combined-Sink ist (z.B. nach Reload)
  219. if current and current != self._sink_name:
  220. self._previous_default = current
  221. ok = await self.set_default_sink(self._sink_name)
  222. if ok:
  223. pinfo(
  224. f"System-Default-Sink → '{self._sink_name}' "
  225. f"(vorher: '{self._previous_default or '?'}')"
  226. )
  227. return ok
  228. async def restore_default(self) -> None:
  229. """Stellt den vorherigen Default-Sink wieder her (wenn vorhanden)."""
  230. if not self._previous_default:
  231. return
  232. if not await self.available():
  233. return
  234. # Nur restoren wenn der aktuelle Default noch unser Combined-Sink ist
  235. current = await self.get_default_sink()
  236. if current == self._sink_name:
  237. ok = await self.set_default_sink(self._previous_default)
  238. if ok:
  239. pdebug(f"System-Default-Sink wiederhergestellt → '{self._previous_default}'")
  240. self._previous_default = None
  241. # === Per-Slave-Lautstaerke ===
  242. async def set_sink_volume(self, sink_name: str, percent: int) -> bool:
  243. """Setzt die Lautstaerke eines beliebigen Sinks (0-100)."""
  244. if not await self.available():
  245. return False
  246. percent = max(0, min(100, percent))
  247. rc, _ = await self._run("set-sink-volume", sink_name, f"{percent}%")
  248. return rc == 0
  249. async def get_sink_volume(self, sink_name: str) -> int:
  250. """Liest die aktuelle Lautstaerke eines Sinks (0-100, Mittelwert)."""
  251. if not await self.available():
  252. return 0
  253. rc, out = await self._run("get-sink-volume", sink_name)
  254. if rc != 0:
  255. return 0
  256. # Ausgabe enthaelt z.B. "Volume: front-left: 52428 / 80% / -5.81 dB, ..."
  257. for line in out.splitlines():
  258. if "%" not in line:
  259. continue
  260. try:
  261. # Alle Prozentwerte sammeln, Mittelwert nehmen
  262. values: list[int] = []
  263. idx = 0
  264. while True:
  265. pct_idx = line.find("%", idx)
  266. if pct_idx < 0:
  267. break
  268. # Zahl rueckwaerts lesen
  269. j = pct_idx - 1
  270. while j >= 0 and (line[j].isdigit() or line[j] == " "):
  271. j -= 1
  272. num = line[j + 1:pct_idx].strip()
  273. if num:
  274. values.append(int(num))
  275. idx = pct_idx + 1
  276. if values:
  277. return sum(values) // len(values)
  278. except Exception:
  279. pass
  280. return 0
  281. # === Hilfsmethoden ===
  282. async def _run(self, *args: str) -> tuple[int, str]:
  283. """Fuehrt einen pactl-Befehl aus, gibt (returncode, stdout+stderr) zurueck.
  284. Erzwingt LC_ALL=C damit die Ausgabe sprachunabhaengig ist —
  285. sonst kommt z.B. auf deutschen Systemen "Ziel #65 / Beschreibung: ..."
  286. statt "Sink #65 / Description: ...".
  287. """
  288. env = os.environ.copy()
  289. env["LC_ALL"] = "C"
  290. env["LANG"] = "C"
  291. try:
  292. proc = await asyncio.create_subprocess_exec(
  293. "pactl", *args,
  294. stdout=asyncio.subprocess.PIPE,
  295. stderr=asyncio.subprocess.STDOUT,
  296. env=env,
  297. )
  298. stdout, _ = await proc.communicate()
  299. return proc.returncode or 0, stdout.decode(errors="replace")
  300. except FileNotFoundError:
  301. return 127, ""
  302. except Exception as e:
  303. perror(f"pactl Fehler: {e}")
  304. return 1, str(e)