volume.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400
  1. # -*- coding: utf-8 -*-
  2. """
  3. ALSA Volume Handler.
  4. Steuert die System-Lautstaerke ueber amixer fuer
  5. Playback (Lautsprecher) und Capture (Mikrofon).
  6. Reagiert auf volume_change und mute_toggle Events
  7. von HID-Geraeten oder anderen Quellen.
  8. Laeuft auf Client und Standalone — regelt die
  9. Hardware-Lautstaerke fuer alle Audio-Ein-/Ausgaben
  10. (TTS, Musik, Sounds, Wakeword-Erkennung).
  11. Quellen-Konzept:
  12. Aenderungen kennen eine "source" (z.B. "user", "duck",
  13. "hid"). Nur Aenderungen mit source="user" oder "hid"
  14. werden in client_config.json persistiert. Ducking
  15. (source="duck") aendert nur die ALSA-Lautstaerke,
  16. nicht die gespeicherten Werte.
  17. """
  18. from __future__ import annotations
  19. import asyncio
  20. import shutil
  21. from typing import TYPE_CHECKING
  22. from trixy_core.utils.debug import pinfo, pdebug, perror
  23. if TYPE_CHECKING:
  24. from trixy_core.application import BaseApplication
  25. # Quellen die in die Config persistiert werden
  26. PERSIST_SOURCES = {"user", "hid"}
  27. class _MixerChannel:
  28. """Ein einzelner ALSA-Mixer-Kanal (Playback ODER Capture)."""
  29. def __init__(
  30. self,
  31. kind: str,
  32. control: str = "",
  33. capture: bool = False,
  34. ) -> None:
  35. self.kind = kind # "output" | "input"
  36. self.control = control
  37. self.capture = capture
  38. self.available = False
  39. self.muted = False
  40. async def detect(self) -> None:
  41. """Erkennt einen passenden Mixer-Control falls keiner gesetzt ist."""
  42. if self.control:
  43. self.available = await _control_exists(self.control)
  44. return
  45. if self.capture:
  46. candidates = ["Capture", "Mic", "Microphone", "Front Mic", "Internal Mic"]
  47. else:
  48. candidates = ["Master", "PCM", "Speaker", "Headphone"]
  49. output = await _amixer("scontrols")
  50. for name in candidates:
  51. if f"'{name}'" in output:
  52. self.control = name
  53. self.available = True
  54. pdebug(f"ALSA {self.kind} Control erkannt: {name}")
  55. return
  56. # Fallback: ersten passenden Control nehmen
  57. for line in output.splitlines():
  58. if "Simple mixer control" in line:
  59. start = line.index("'") + 1
  60. end = line.index("'", start)
  61. name = line[start:end]
  62. self.control = name
  63. self.available = True
  64. pdebug(f"ALSA {self.kind} Control (Fallback): {name}")
  65. return
  66. async def get_volume(self) -> int:
  67. if not self.available:
  68. return 0
  69. try:
  70. output = await _amixer("get", self.control)
  71. for line in output.splitlines():
  72. if "%" in line:
  73. start = line.index("[") + 1
  74. end = line.index("%", start)
  75. return int(line[start:end])
  76. except Exception:
  77. pass
  78. return 0
  79. async def set_volume(self, percent: int) -> None:
  80. if not self.available:
  81. return
  82. percent = max(0, min(100, percent))
  83. await _amixer("set", self.control, f"{percent}%")
  84. pdebug(f"ALSA {self.kind} Volume: {percent}%")
  85. async def set_mute(self, muted: bool) -> None:
  86. if not self.available:
  87. return
  88. await _amixer("set", self.control, "mute" if muted else "unmute")
  89. self.muted = muted
  90. pdebug(f"ALSA {self.kind} {'MUTE' if muted else 'UNMUTE'}")
  91. async def read_mute(self) -> bool:
  92. if not self.available:
  93. return False
  94. output = await _amixer("get", self.control)
  95. self.muted = "[off]" in output
  96. return self.muted
  97. async def _amixer(*args: str) -> str:
  98. """Fuehrt einen amixer-Befehl aus."""
  99. try:
  100. proc = await asyncio.create_subprocess_exec(
  101. "amixer", *args,
  102. stdout=asyncio.subprocess.PIPE,
  103. stderr=asyncio.subprocess.DEVNULL,
  104. )
  105. stdout, _ = await proc.communicate()
  106. return stdout.decode()
  107. except Exception as e:
  108. perror(f"amixer Fehler: {e}")
  109. return ""
  110. async def _control_exists(name: str) -> bool:
  111. output = await _amixer("scontrols")
  112. return f"'{name}'" in output
  113. class VolumeHandler:
  114. """
  115. ALSA Volume Handler fuer Playback und Capture.
  116. Persistiert Aenderungen mit source in PERSIST_SOURCES
  117. in client_config.json. Ducking-Aenderungen (source="duck")
  118. werden NICHT persistiert.
  119. """
  120. def __init__(
  121. self,
  122. application: "BaseApplication",
  123. output_control: str = "",
  124. input_control: str = "",
  125. config_name: str = "client",
  126. ) -> None:
  127. self._application = application
  128. self._output = _MixerChannel("output", output_control, capture=False)
  129. self._input = _MixerChannel("input", input_control, capture=True)
  130. self._available = False
  131. self._config_name = config_name
  132. @property
  133. def available(self) -> bool:
  134. return self._available
  135. @property
  136. def output(self) -> _MixerChannel:
  137. return self._output
  138. @property
  139. def input(self) -> _MixerChannel:
  140. return self._input
  141. async def start(self) -> None:
  142. """Startet den Volume Handler und wendet Config-Werte an."""
  143. if not shutil.which("amixer"):
  144. pdebug("amixer nicht gefunden — Volume Handler deaktiviert")
  145. return
  146. await self._output.detect()
  147. await self._input.detect()
  148. if not (self._output.available or self._input.available):
  149. pdebug("Kein ALSA Mixer Control gefunden")
  150. return
  151. self._available = True
  152. # Config-Werte beim Start anwenden
  153. await self._apply_config()
  154. events = getattr(self._application, "events", None)
  155. if events:
  156. events.register("volume_change", self._on_volume_change)
  157. events.register("mute_toggle", self._on_mute_toggle)
  158. events.register("audio_volume_set", self._on_volume_set)
  159. events.register("audio_mute_set", self._on_mute_set)
  160. out_v = await self._output.get_volume()
  161. in_v = await self._input.get_volume()
  162. pinfo(
  163. f"Volume Handler gestartet — "
  164. f"Out: {self._output.control} ({out_v}%{', MUTE' if self._output.muted else ''}) "
  165. f"In: {self._input.control} ({in_v}%{', MUTE' if self._input.muted else ''})"
  166. )
  167. async def stop(self) -> None:
  168. events = getattr(self._application, "events", None)
  169. if events:
  170. events.unregister("volume_change", self._on_volume_change)
  171. events.unregister("mute_toggle", self._on_mute_toggle)
  172. events.unregister("audio_volume_set", self._on_volume_set)
  173. events.unregister("audio_mute_set", self._on_mute_set)
  174. # === Config-Anbindung ===
  175. def _audio_config(self):
  176. cfg = getattr(self._application, "_client_config", None)
  177. if cfg is None:
  178. cfg = getattr(self._application, "client_config", None)
  179. return getattr(cfg, "audio", None) if cfg else None
  180. async def _apply_config(self) -> None:
  181. """Wendet die in client_config.json gespeicherten Werte auf ALSA an."""
  182. ac = self._audio_config()
  183. if ac is None:
  184. return
  185. if self._output.available:
  186. await self._output.set_volume(int(ac.output_volume))
  187. await self._output.set_mute(bool(ac.output_muted))
  188. if self._input.available:
  189. await self._input.set_volume(int(ac.input_volume))
  190. await self._input.set_mute(bool(ac.input_muted))
  191. def _persist(self, **changes) -> None:
  192. """Schreibt Aenderungen in client_config.json."""
  193. ac = self._audio_config()
  194. if ac is None:
  195. return
  196. for key, value in changes.items():
  197. if hasattr(ac, key):
  198. setattr(ac, key, value)
  199. cm = getattr(self._application, "config_manager", None)
  200. if cm is None:
  201. return
  202. try:
  203. cm.save(self._config_name)
  204. except Exception as e:
  205. perror(f"Volume-Persistenz fehlgeschlagen: {e}")
  206. # === Public API ===
  207. async def set_output_volume(self, percent: int, source: str = "user") -> None:
  208. if not self._output.available:
  209. return
  210. await self._output.set_volume(percent)
  211. if source in PERSIST_SOURCES:
  212. self._persist(output_volume=max(0, min(100, percent)))
  213. await self._emit_changed("output", percent, source)
  214. async def set_input_volume(self, percent: int, source: str = "user") -> None:
  215. if not self._input.available:
  216. return
  217. await self._input.set_volume(percent)
  218. if source in PERSIST_SOURCES:
  219. self._persist(input_volume=max(0, min(100, percent)))
  220. await self._emit_changed("input", percent, source)
  221. async def set_output_mute(self, muted: bool, source: str = "user") -> None:
  222. if not self._output.available:
  223. return
  224. await self._output.set_mute(muted)
  225. if source in PERSIST_SOURCES:
  226. self._persist(output_muted=muted)
  227. async def set_input_mute(self, muted: bool, source: str = "user") -> None:
  228. if not self._input.available:
  229. return
  230. await self._input.set_mute(muted)
  231. if source in PERSIST_SOURCES:
  232. self._persist(input_muted=muted)
  233. async def restore_from_config(self, channel: str = "output") -> None:
  234. """Stellt die in der Config gespeicherte Lautstaerke wieder her.
  235. Wird nach Ducking aufgerufen — nutzt config-Werte statt
  236. temporaerer Variablen (solider).
  237. """
  238. ac = self._audio_config()
  239. if ac is None:
  240. return
  241. if channel in ("output", "all") and self._output.available:
  242. await self._output.set_volume(int(ac.output_volume))
  243. await self._output.set_mute(bool(ac.output_muted))
  244. if channel in ("input", "all") and self._input.available:
  245. await self._input.set_volume(int(ac.input_volume))
  246. await self._input.set_mute(bool(ac.input_muted))
  247. async def get_status(self) -> dict:
  248. """Gibt aktuellen Status zurueck — fuer F6 Audio View."""
  249. return {
  250. "output": {
  251. "available": self._output.available,
  252. "control": self._output.control,
  253. "volume": await self._output.get_volume() if self._output.available else 0,
  254. "muted": await self._output.read_mute() if self._output.available else False,
  255. },
  256. "input": {
  257. "available": self._input.available,
  258. "control": self._input.control,
  259. "volume": await self._input.get_volume() if self._input.available else 0,
  260. "muted": await self._input.read_mute() if self._input.available else False,
  261. },
  262. }
  263. # === Event-Handler ===
  264. async def _on_volume_change(self, event_name: str, data: dict) -> None:
  265. """Handler fuer relative Aenderungen (HID Hotkeys)."""
  266. direction = data.get("direction", "")
  267. step = data.get("step", 0.05)
  268. target = data.get("target", "output")
  269. step_pct = max(1, int(step * 100))
  270. channel = self._output if target == "output" else self._input
  271. if not channel.available:
  272. return
  273. current = await channel.get_volume()
  274. new_value = current + step_pct if direction == "up" else current - step_pct
  275. if target == "output":
  276. await self.set_output_volume(new_value, source="hid")
  277. else:
  278. await self.set_input_volume(new_value, source="hid")
  279. async def _on_mute_toggle(self, event_name: str, data: dict) -> None:
  280. mute_type = data.get("type", "speaker")
  281. if mute_type == "speaker":
  282. await self.set_output_mute(not self._output.muted, source="hid")
  283. elif mute_type in ("mic", "microphone"):
  284. await self.set_input_mute(not self._input.muted, source="hid")
  285. async def _on_volume_set(self, event_name: str, data: dict) -> None:
  286. """Handler fuer absolute Volume-Aenderungen (z.B. F6 Audio View, Ducking)."""
  287. target = data.get("target", "output")
  288. percent = int(data.get("volume", 0))
  289. source = data.get("source", "user")
  290. if target == "output":
  291. await self.set_output_volume(percent, source=source)
  292. else:
  293. await self.set_input_volume(percent, source=source)
  294. async def _on_mute_set(self, event_name: str, data: dict) -> None:
  295. target = data.get("target", "output")
  296. muted = bool(data.get("muted", False))
  297. source = data.get("source", "user")
  298. if target == "output":
  299. await self.set_output_mute(muted, source=source)
  300. else:
  301. await self.set_input_mute(muted, source=source)
  302. async def _emit_changed(self, target: str, percent: int, source: str) -> None:
  303. events = getattr(self._application, "events", None)
  304. if events:
  305. await events.emit("audio_level_changed", {
  306. "target": target,
  307. "volume": percent,
  308. "source": source,
  309. })
  310. # === Legacy-Kompatibilitaet ===
  311. @property
  312. def mixer_control(self) -> str:
  313. return self._output.control
  314. @property
  315. def is_muted(self) -> bool:
  316. return self._output.muted
  317. async def get_volume(self) -> int:
  318. return await self._output.get_volume()
  319. async def set_volume(self, percent: int) -> None:
  320. await self.set_output_volume(percent, source="user")
  321. async def volume_up(self, step_percent: int = 5) -> None:
  322. current = await self._output.get_volume()
  323. await self.set_output_volume(current + step_percent, source="hid")
  324. async def volume_down(self, step_percent: int = 5) -> None:
  325. current = await self._output.get_volume()
  326. await self.set_output_volume(current - step_percent, source="hid")
  327. async def mute(self) -> None:
  328. await self.set_output_mute(True, source="user")
  329. async def unmute(self) -> None:
  330. await self.set_output_mute(False, source="user")
  331. async def toggle_mute(self) -> None:
  332. await self.set_output_mute(not self._output.muted, source="user")