pulse_mic_mix.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370
  1. # -*- coding: utf-8 -*-
  2. """
  3. PulseAudio / PipeWire Multi-Mic-Mix Wrapper.
  4. Spiegelt `PulseCombinedSink` fuer die Input-Seite: mehrere
  5. Mikrofone werden zu einer einzigen virtuellen Quelle gemischt,
  6. sodass die Wakeword-Detection und das Server-Streaming
  7. unveraendert mit EINER Quelle arbeiten koennen.
  8. Technik:
  9. 1. `module-null-sink sink_name=trixy_mics_combined`
  10. → erzeugt einen virtuellen Sink mit angehaengtem
  11. `.monitor` Source.
  12. 2. Pro Mikrofon ein `module-loopback source=<mic> sink=trixy_mics_combined`
  13. → leitet das Mic-Signal in den Null-Sink.
  14. 3. Default-Source wird auf `trixy_mics_combined.monitor`
  15. gesetzt, sodass alle Aufnehmer den Mix sehen.
  16. 4. Vorheriger Default-Source wird beim Teardown wiederhergestellt.
  17. Warum kein `module-combine-source`? Den gibt es in Pulse nicht
  18. (nur `module-combine-sink` fuer Output). Der null-sink+loopback
  19. Trick ist der etablierte Workaround.
  20. Einschraenkungen: wie PulseCombinedSink. Nur verfuegbar wo
  21. `pactl` antwortet (Linux mit Pulse/PipeWire, WSL2 mit WSLg).
  22. """
  23. from __future__ import annotations
  24. import asyncio
  25. import os
  26. import shutil
  27. from typing import NamedTuple
  28. from trixy_core.utils.debug import pinfo, pdebug, perror
  29. class PulseSource(NamedTuple):
  30. """Ein einzelner PulseAudio/PipeWire Input-Source (Mikrofon)."""
  31. index: int
  32. name: str
  33. description: str
  34. state: str
  35. monitor_of: str # falls es ein Monitor-Source ist, sonst ""
  36. class PulseMicrophoneMix:
  37. """Verwaltet den Multi-Mic-Mix ueber pactl."""
  38. DEFAULT_SINK_NAME = "trixy_mics_combined"
  39. def __init__(self, sink_name: str = DEFAULT_SINK_NAME) -> None:
  40. self._sink_name = sink_name
  41. self._null_sink_module: int | None = None
  42. self._loopback_modules: list[int] = []
  43. self._sources: list[str] = []
  44. self._available: bool | None = None
  45. self._previous_default: str | None = None
  46. @property
  47. def sink_name(self) -> str:
  48. return self._sink_name
  49. @property
  50. def monitor_source_name(self) -> str:
  51. """Name des resultierenden virtuellen Source-Namens."""
  52. return f"{self._sink_name}.monitor"
  53. @property
  54. def sources(self) -> list[str]:
  55. return list(self._sources)
  56. @property
  57. def active(self) -> bool:
  58. return self._null_sink_module is not None
  59. async def available(self) -> bool:
  60. """True wenn pactl + Pulse/PipeWire-Server erreichbar sind."""
  61. if self._available is not None:
  62. return self._available
  63. if not shutil.which("pactl"):
  64. pdebug("pactl nicht gefunden — Mic-Mix deaktiviert")
  65. self._available = False
  66. return False
  67. rc, _ = await self._run("info")
  68. self._available = rc == 0
  69. return self._available
  70. # === Source-Listing ===
  71. async def list_sources(self, include_monitors: bool = False) -> list[PulseSource]:
  72. """Listet alle realen Input-Sources (ohne unseren eigenen Monitor).
  73. Args:
  74. include_monitors: Wenn False (Default), werden .monitor-Sources
  75. anderer Sinks herausgefiltert — wir wollen nur
  76. echte Mikrofone.
  77. """
  78. if not await self.available():
  79. return []
  80. rc, out = await self._run("list", "sources")
  81. if rc != 0:
  82. return []
  83. all_sources = self._parse_sources(out)
  84. result: list[PulseSource] = []
  85. for s in all_sources:
  86. # Eigenen Monitor ausblenden
  87. if s.name == self.monitor_source_name:
  88. continue
  89. # Monitor-Sources optional ausblenden
  90. if not include_monitors and s.monitor_of:
  91. continue
  92. result.append(s)
  93. return result
  94. @staticmethod
  95. def _parse_sources(text: str) -> list[PulseSource]:
  96. """Parst die Ausgabe von `pactl list sources`."""
  97. sources: list[PulseSource] = []
  98. current: dict = {}
  99. def flush() -> None:
  100. if not current:
  101. return
  102. try:
  103. sources.append(PulseSource(
  104. index=int(current.get("index", -1)),
  105. name=current.get("name", ""),
  106. description=current.get("description", current.get("name", "")),
  107. state=current.get("state", ""),
  108. monitor_of=current.get("monitor_of", ""),
  109. ))
  110. except Exception:
  111. pass
  112. current.clear()
  113. for raw in text.splitlines():
  114. line = raw.rstrip()
  115. if line.startswith("Source #"):
  116. flush()
  117. try:
  118. current["index"] = int(line.split("#", 1)[1])
  119. except Exception:
  120. pass
  121. continue
  122. stripped = line.lstrip()
  123. if stripped.startswith("Name:"):
  124. current["name"] = stripped.split(":", 1)[1].strip()
  125. elif stripped.startswith("Description:"):
  126. current["description"] = stripped.split(":", 1)[1].strip()
  127. elif stripped.startswith("State:"):
  128. current["state"] = stripped.split(":", 1)[1].strip()
  129. elif stripped.startswith("Monitor of Sink:"):
  130. value = stripped.split(":", 1)[1].strip()
  131. # Pulse/PipeWire setzt "n/a" fuer Nicht-Monitor-Sources
  132. current["monitor_of"] = "" if value.lower() in ("n/a", "") else value
  133. flush()
  134. return sources
  135. # === Mix-Lifecycle ===
  136. async def find_existing_null_sink(self) -> int | None:
  137. """Sucht ein bereits geladenes module-null-sink mit unserem Namen."""
  138. rc, out = await self._run("list", "modules", "short")
  139. if rc != 0:
  140. return None
  141. for line in out.splitlines():
  142. parts = line.split("\t")
  143. if len(parts) < 2:
  144. continue
  145. try:
  146. idx = int(parts[0])
  147. except ValueError:
  148. continue
  149. module = parts[1]
  150. args = parts[2] if len(parts) >= 3 else ""
  151. if module == "module-null-sink" and f"sink_name={self._sink_name}" in args:
  152. return idx
  153. return None
  154. async def _find_our_loopbacks(self) -> list[int]:
  155. """Sucht alle loopback-Module die auf unseren Null-Sink zeigen."""
  156. rc, out = await self._run("list", "modules", "short")
  157. if rc != 0:
  158. return []
  159. result: list[int] = []
  160. for line in out.splitlines():
  161. parts = line.split("\t")
  162. if len(parts) < 2:
  163. continue
  164. if parts[1] != "module-loopback":
  165. continue
  166. try:
  167. idx = int(parts[0])
  168. except ValueError:
  169. continue
  170. args = parts[2] if len(parts) >= 3 else ""
  171. if f"sink={self._sink_name}" in args:
  172. result.append(idx)
  173. return result
  174. async def setup(self, sources: list[str]) -> bool:
  175. """Erstellt den Mic-Mix mit den gewaehlten realen Sources.
  176. Args:
  177. sources: Liste der Source-Namen die gemischt werden sollen.
  178. Returns:
  179. True bei Erfolg, False sonst.
  180. """
  181. if not await self.available():
  182. return False
  183. if not sources:
  184. await self.teardown()
  185. return False
  186. if len(sources) == 1:
  187. # Ein einzelner Mic braucht keinen Mix — Caller soll
  188. # den Mic direkt als Default nutzen.
  189. await self.teardown()
  190. return False
  191. # Aufraeumen falls etwas altes noch haengt
  192. await self.teardown()
  193. # 1. Null-Sink erstellen
  194. rc, out = await self._run(
  195. "load-module",
  196. "module-null-sink",
  197. f"sink_name={self._sink_name}",
  198. "sink_properties=device.description=Trixy_Multi_Mic_Mix",
  199. )
  200. if rc != 0:
  201. perror(f"Null-Sink konnte nicht erstellt werden: {out.strip()}")
  202. return False
  203. try:
  204. self._null_sink_module = int(out.strip().splitlines()[-1])
  205. except Exception:
  206. self._null_sink_module = await self.find_existing_null_sink()
  207. # 2. Pro Mic ein Loopback in den Null-Sink
  208. self._loopback_modules = []
  209. self._sources = []
  210. for source in sources:
  211. rc, out = await self._run(
  212. "load-module",
  213. "module-loopback",
  214. f"source={source}",
  215. f"sink={self._sink_name}",
  216. "latency_msec=30",
  217. "source_dont_move=true",
  218. "sink_dont_move=true",
  219. )
  220. if rc != 0:
  221. pdebug(f"Loopback fuer '{source}' fehlgeschlagen: {out.strip()}")
  222. continue
  223. try:
  224. idx = int(out.strip().splitlines()[-1])
  225. self._loopback_modules.append(idx)
  226. self._sources.append(source)
  227. except Exception:
  228. pass
  229. if not self._loopback_modules:
  230. # Kein einziger Loopback erfolgreich — Null-Sink wieder abbauen
  231. await self.teardown()
  232. return False
  233. pinfo(
  234. f"Mic-Mix aktiv: {len(self._loopback_modules)} Mikrofone → "
  235. f"'{self.monitor_source_name}' (Null-Sink #{self._null_sink_module})"
  236. )
  237. return True
  238. async def teardown(self) -> None:
  239. """Baut den Mix wieder ab und stellt den alten Default-Source wieder her."""
  240. if not await self.available():
  241. return
  242. # Vorherigen Default zuerst wiederherstellen, sonst ist er
  243. # kurz undefiniert wenn wir unseren .monitor-Source entladen.
  244. await self.restore_default_source()
  245. # Alle unsere Loopbacks entladen
  246. loopbacks = list(self._loopback_modules)
  247. if not loopbacks:
  248. # Keiner im Cache? Alle loopbacks suchen die auf uns zeigen.
  249. loopbacks = await self._find_our_loopbacks()
  250. for idx in loopbacks:
  251. await self._run("unload-module", str(idx))
  252. self._loopback_modules = []
  253. # Null-Sink entladen
  254. sink_idx = self._null_sink_module
  255. if sink_idx is None:
  256. sink_idx = await self.find_existing_null_sink()
  257. if sink_idx is not None:
  258. rc, _ = await self._run("unload-module", str(sink_idx))
  259. if rc == 0:
  260. pdebug(f"Mic-Mix Null-Sink #{sink_idx} entladen")
  261. self._null_sink_module = None
  262. self._sources = []
  263. # === Default-Source-Verwaltung ===
  264. async def get_default_source(self) -> str:
  265. """Liest den aktuellen System-Default-Source-Namen."""
  266. if not await self.available():
  267. return ""
  268. rc, out = await self._run("get-default-source")
  269. if rc != 0:
  270. return ""
  271. return out.strip().splitlines()[0] if out.strip() else ""
  272. async def set_default_source(self, source_name: str) -> bool:
  273. """Setzt den System-Default-Source."""
  274. if not await self.available():
  275. return False
  276. rc, _ = await self._run("set-default-source", source_name)
  277. return rc == 0
  278. async def make_default(self) -> bool:
  279. """Macht unseren Monitor zum System-Default-Source und merkt sich den vorherigen."""
  280. if self._null_sink_module is None:
  281. return False
  282. current = await self.get_default_source()
  283. if current and current != self.monitor_source_name:
  284. self._previous_default = current
  285. ok = await self.set_default_source(self.monitor_source_name)
  286. if ok:
  287. pinfo(
  288. f"System-Default-Source → '{self.monitor_source_name}' "
  289. f"(vorher: '{self._previous_default or '?'}')"
  290. )
  291. return ok
  292. async def restore_default_source(self) -> None:
  293. """Stellt den vorherigen Default-Source wieder her."""
  294. if not self._previous_default:
  295. return
  296. if not await self.available():
  297. return
  298. current = await self.get_default_source()
  299. if current == self.monitor_source_name:
  300. ok = await self.set_default_source(self._previous_default)
  301. if ok:
  302. pdebug(f"System-Default-Source wiederhergestellt → '{self._previous_default}'")
  303. self._previous_default = None
  304. # === Hilfsmethoden ===
  305. async def _run(self, *args: str) -> tuple[int, str]:
  306. """pactl mit LC_ALL=C damit die Ausgabe sprachunabhaengig ist."""
  307. env = os.environ.copy()
  308. env["LC_ALL"] = "C"
  309. env["LANG"] = "C"
  310. try:
  311. proc = await asyncio.create_subprocess_exec(
  312. "pactl", *args,
  313. stdout=asyncio.subprocess.PIPE,
  314. stderr=asyncio.subprocess.STDOUT,
  315. env=env,
  316. )
  317. stdout, _ = await proc.communicate()
  318. return proc.returncode or 0, stdout.decode(errors="replace")
  319. except FileNotFoundError:
  320. return 127, ""
  321. except Exception as e:
  322. perror(f"pactl Fehler: {e}")
  323. return 1, str(e)