volume.py 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. # -*- coding: utf-8 -*-
  2. """
  3. ALSA Volume Handler.
  4. Steuert die System-Lautstaerke ueber amixer.
  5. Reagiert auf volume_change und mute_toggle Events
  6. von HID-Geraeten oder anderen Quellen.
  7. Laeuft auf Client und Standalone — regelt die
  8. Hardware-Lautstaerke fuer alle Audio-Ausgaben
  9. (TTS, Musik, Sounds).
  10. """
  11. from __future__ import annotations
  12. import asyncio
  13. import shutil
  14. from typing import TYPE_CHECKING
  15. from trixy_core.utils.debug import pinfo, pdebug, perror
  16. if TYPE_CHECKING:
  17. from trixy_core.application import BaseApplication
  18. class VolumeHandler:
  19. """
  20. ALSA Volume Handler.
  21. Registriert sich auf volume_change und mute_toggle Events
  22. und steuert den System-Mixer via amixer.
  23. Beispiel:
  24. ```python
  25. handler = VolumeHandler(app, mixer_control="Master")
  26. await handler.start()
  27. # Wird automatisch bei HID-Events aufgerufen:
  28. # volume_change {direction: "up", step: 0.05}
  29. # mute_toggle {type: "speaker"}
  30. ```
  31. """
  32. def __init__(
  33. self,
  34. application: "BaseApplication",
  35. mixer_control: str = "",
  36. ) -> None:
  37. """
  38. Args:
  39. application: Anwendungsreferenz
  40. mixer_control: ALSA Mixer Control (z.B. "Master", "PCM")
  41. Leer = automatisch erkennen
  42. """
  43. self._application = application
  44. self._mixer_control = mixer_control
  45. self._muted = False
  46. self._available = False
  47. @property
  48. def available(self) -> bool:
  49. """True wenn amixer verfuegbar und Mixer-Control gefunden."""
  50. return self._available
  51. @property
  52. def mixer_control(self) -> str:
  53. """Name des aktiven ALSA Mixer Controls."""
  54. return self._mixer_control
  55. @property
  56. def is_muted(self) -> bool:
  57. """Ob der Mixer stummgeschaltet ist."""
  58. return self._muted
  59. async def start(self) -> None:
  60. """Startet den Volume Handler und registriert Event-Listener."""
  61. if not shutil.which("amixer"):
  62. pdebug("amixer nicht gefunden — Volume Handler deaktiviert")
  63. return
  64. # Mixer-Control erkennen
  65. if not self._mixer_control:
  66. self._mixer_control = await self._detect_mixer_control()
  67. if not self._mixer_control:
  68. pdebug("Kein ALSA Mixer Control gefunden")
  69. return
  70. self._available = True
  71. # Aktuellen Mute-Status lesen
  72. self._muted = await self._get_mute_state()
  73. # Events registrieren
  74. events = getattr(self._application, "events", None)
  75. if events:
  76. events.register("volume_change", self._on_volume_change)
  77. events.register("mute_toggle", self._on_mute_toggle)
  78. volume = await self.get_volume()
  79. pinfo(
  80. f"Volume Handler gestartet: {self._mixer_control} "
  81. f"({volume}%{', MUTE' if self._muted else ''})"
  82. )
  83. async def stop(self) -> None:
  84. """Stoppt den Volume Handler."""
  85. events = getattr(self._application, "events", None)
  86. if events:
  87. events.unregister("volume_change", self._on_volume_change)
  88. events.unregister("mute_toggle", self._on_mute_toggle)
  89. # === Lautstaerke-Steuerung ===
  90. async def get_volume(self) -> int:
  91. """Gibt die aktuelle Lautstaerke in Prozent zurueck (0-100)."""
  92. if not self._available:
  93. return 0
  94. try:
  95. proc = await asyncio.create_subprocess_exec(
  96. "amixer", "get", self._mixer_control,
  97. stdout=asyncio.subprocess.PIPE,
  98. stderr=asyncio.subprocess.DEVNULL,
  99. )
  100. stdout, _ = await proc.communicate()
  101. output = stdout.decode()
  102. # "[75%]" aus der Ausgabe parsen
  103. for line in output.splitlines():
  104. if "%" in line:
  105. start = line.index("[") + 1
  106. end = line.index("%")
  107. return int(line[start:end])
  108. except Exception:
  109. pass
  110. return 0
  111. async def set_volume(self, percent: int) -> None:
  112. """Setzt die Lautstaerke in Prozent (0-100)."""
  113. if not self._available:
  114. return
  115. percent = max(0, min(100, percent))
  116. await self._amixer("set", self._mixer_control, f"{percent}%")
  117. pdebug(f"Volume: {percent}%")
  118. # Event emittieren
  119. events = getattr(self._application, "events", None)
  120. if events:
  121. await events.emit("audio_level_changed", {
  122. "volume": percent,
  123. "source": "alsa",
  124. })
  125. async def volume_up(self, step_percent: int = 5) -> None:
  126. """Erhoeht die Lautstaerke."""
  127. await self._amixer("set", self._mixer_control, f"{step_percent}%+")
  128. volume = await self.get_volume()
  129. pdebug(f"Volume: +{step_percent}% → {volume}%")
  130. async def volume_down(self, step_percent: int = 5) -> None:
  131. """Verringert die Lautstaerke."""
  132. await self._amixer("set", self._mixer_control, f"{step_percent}%-")
  133. volume = await self.get_volume()
  134. pdebug(f"Volume: -{step_percent}% → {volume}%")
  135. async def mute(self) -> None:
  136. """Schaltet stumm."""
  137. await self._amixer("set", self._mixer_control, "mute")
  138. self._muted = True
  139. pdebug("Volume: MUTE")
  140. async def unmute(self) -> None:
  141. """Hebt Stummschaltung auf."""
  142. await self._amixer("set", self._mixer_control, "unmute")
  143. self._muted = False
  144. pdebug("Volume: UNMUTE")
  145. async def toggle_mute(self) -> None:
  146. """Schaltet Stummschaltung um."""
  147. if self._muted:
  148. await self.unmute()
  149. else:
  150. await self.mute()
  151. # === Event-Handler ===
  152. async def _on_volume_change(self, event_name: str, data: dict) -> None:
  153. """Handler fuer volume_change Event (von HID oder anderen Quellen)."""
  154. direction = data.get("direction", "")
  155. step = data.get("step", 0.05)
  156. step_pct = max(1, int(step * 100))
  157. if direction == "up":
  158. await self.volume_up(step_pct)
  159. elif direction == "down":
  160. await self.volume_down(step_pct)
  161. async def _on_mute_toggle(self, event_name: str, data: dict) -> None:
  162. """Handler fuer mute_toggle Event."""
  163. mute_type = data.get("type", "speaker")
  164. # Nur auf Lautsprecher-Mute reagieren, nicht auf Mikrofon
  165. if mute_type == "speaker":
  166. await self.toggle_mute()
  167. # === Hilfsmethoden ===
  168. async def _amixer(self, *args: str) -> str:
  169. """Fuehrt einen amixer-Befehl aus."""
  170. try:
  171. proc = await asyncio.create_subprocess_exec(
  172. "amixer", *args,
  173. stdout=asyncio.subprocess.PIPE,
  174. stderr=asyncio.subprocess.DEVNULL,
  175. )
  176. stdout, _ = await proc.communicate()
  177. return stdout.decode()
  178. except Exception as e:
  179. perror(f"amixer Fehler: {e}")
  180. return ""
  181. async def _detect_mixer_control(self) -> str:
  182. """Erkennt den passenden ALSA Mixer Control."""
  183. # Reihenfolge: Master > PCM > Speaker > Headphone
  184. candidates = ["Master", "PCM", "Speaker", "Headphone"]
  185. output = await self._amixer("scontrols")
  186. for name in candidates:
  187. if f"'{name}'" in output:
  188. pdebug(f"ALSA Mixer Control erkannt: {name}")
  189. return name
  190. # Fallback: ersten Control nehmen
  191. for line in output.splitlines():
  192. if "Simple mixer control" in line:
  193. # "Simple mixer control 'Master',0" → "Master"
  194. start = line.index("'") + 1
  195. end = line.index("'", start)
  196. name = line[start:end]
  197. pdebug(f"ALSA Mixer Control (Fallback): {name}")
  198. return name
  199. return ""
  200. async def _get_mute_state(self) -> bool:
  201. """Liest den aktuellen Mute-Status."""
  202. output = await self._amixer("get", self._mixer_control)
  203. return "[off]" in output