window.py 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. # -*- coding: utf-8 -*-
  2. """
  3. Arbitration-Zeitfenster Management.
  4. Verwaltet das Zeitfenster, in dem Wakeword-Erkennungen gesammelt werden,
  5. bevor eine Entscheidung getroffen wird.
  6. """
  7. import asyncio
  8. from dataclasses import dataclass, field
  9. from datetime import datetime
  10. from enum import Enum
  11. from typing import Callable, Awaitable, Any
  12. from trixy_core.arbitration.candidate import ArbitrationCandidate, CandidateState
  13. class WindowState(Enum):
  14. """Zustand des Arbitration-Fensters."""
  15. CLOSED = "closed" # Kein aktives Fenster
  16. OPEN = "open" # Fenster offen, sammelt Kandidaten
  17. DECIDING = "deciding" # Entscheidung wird getroffen
  18. COMPLETED = "completed" # Entscheidung abgeschlossen
  19. @dataclass
  20. class WindowConfig:
  21. """Konfiguration für das Arbitration-Fenster."""
  22. # Zeitfenster in Millisekunden
  23. window_duration_ms: int = 1000
  24. # Minimale Wartezeit bevor Entscheidung (auch wenn nur 1 Kandidat)
  25. min_wait_ms: int = 100
  26. # Maximale Anzahl Kandidaten
  27. max_candidates: int = 10
  28. # Sofortige Entscheidung wenn audio_level über diesem Wert
  29. immediate_threshold: float = 0.95
  30. # Vorzeitiges Schließen wenn Differenz zum Besten groß genug
  31. early_close_threshold: float = 0.3
  32. class ArbitrationWindow:
  33. """
  34. Zeitfenster für die Wakeword-Arbitration.
  35. Sammelt WakewordDetected-Ereignisse von mehreren Satellites
  36. innerhalb eines Zeitfensters und ermöglicht dann die Auswahl.
  37. """
  38. def __init__(
  39. self,
  40. config: WindowConfig | None = None,
  41. on_complete: Callable[[list[ArbitrationCandidate]], Awaitable[None]] | None = None,
  42. ) -> None:
  43. """
  44. Initialisiert das Arbitration-Fenster.
  45. Args:
  46. config: Fenster-Konfiguration
  47. on_complete: Callback wenn Fenster abgeschlossen
  48. """
  49. self.config = config or WindowConfig()
  50. self.on_complete = on_complete
  51. self._state = WindowState.CLOSED
  52. self._candidates: list[ArbitrationCandidate] = []
  53. self._opened_at: datetime | None = None
  54. self._timer_task: asyncio.Task | None = None
  55. self._lock = asyncio.Lock()
  56. @property
  57. def state(self) -> WindowState:
  58. """Aktueller Fensterzustand."""
  59. return self._state
  60. @property
  61. def is_open(self) -> bool:
  62. """Prüft ob Fenster offen ist."""
  63. return self._state == WindowState.OPEN
  64. @property
  65. def is_closed(self) -> bool:
  66. """Prüft ob Fenster geschlossen ist."""
  67. return self._state == WindowState.CLOSED
  68. @property
  69. def candidates(self) -> list[ArbitrationCandidate]:
  70. """Liste der aktuellen Kandidaten."""
  71. return self._candidates.copy()
  72. @property
  73. def candidate_count(self) -> int:
  74. """Anzahl der Kandidaten."""
  75. return len(self._candidates)
  76. @property
  77. def duration_ms(self) -> float:
  78. """Bisherige Dauer des Fensters in Millisekunden."""
  79. if not self._opened_at:
  80. return 0.0
  81. delta = datetime.now() - self._opened_at
  82. return delta.total_seconds() * 1000
  83. @property
  84. def remaining_ms(self) -> float:
  85. """Verbleibende Zeit bis Fenster schließt."""
  86. if not self.is_open:
  87. return 0.0
  88. remaining = self.config.window_duration_ms - self.duration_ms
  89. return max(0.0, remaining)
  90. async def open(self) -> bool:
  91. """
  92. Öffnet das Arbitration-Fenster.
  93. Returns:
  94. True wenn erfolgreich geöffnet, False wenn bereits offen
  95. """
  96. async with self._lock:
  97. if self._state != WindowState.CLOSED:
  98. return False
  99. self._state = WindowState.OPEN
  100. self._candidates = []
  101. self._opened_at = datetime.now()
  102. # Timer starten
  103. self._timer_task = asyncio.create_task(self._window_timer())
  104. return True
  105. async def add_candidate(self, candidate: ArbitrationCandidate) -> bool:
  106. """
  107. Fügt einen Kandidaten zum Fenster hinzu.
  108. Args:
  109. candidate: Der hinzuzufügende Kandidat
  110. Returns:
  111. True wenn erfolgreich hinzugefügt
  112. """
  113. async with self._lock:
  114. if not self.is_open:
  115. return False
  116. # Prüfe ob Satellite bereits Kandidat ist
  117. for existing in self._candidates:
  118. if existing.satellite_id == candidate.satellite_id:
  119. # Aktualisiere wenn neuer Wert besser
  120. if candidate.audio_level > existing.audio_level:
  121. existing.audio_level = candidate.audio_level
  122. existing.confidence = candidate.confidence
  123. existing.detected_at = candidate.detected_at
  124. return True
  125. # Maximale Anzahl prüfen
  126. if len(self._candidates) >= self.config.max_candidates:
  127. return False
  128. self._candidates.append(candidate)
  129. # Prüfe auf sofortige Entscheidung
  130. # Hinweis: _close_early wird außerhalb des Locks aufgerufen
  131. should_close_early = candidate.audio_level >= self.config.immediate_threshold
  132. # Außerhalb des Locks
  133. if should_close_early:
  134. asyncio.create_task(self._close_early("immediate_threshold"))
  135. return True
  136. async def close(self, reason: str = "manual") -> list[ArbitrationCandidate]:
  137. """
  138. Schließt das Fenster und gibt Kandidaten zurück.
  139. Args:
  140. reason: Grund für das Schließen
  141. Returns:
  142. Liste der Kandidaten
  143. """
  144. # Timer außerhalb des Locks abbrechen um Deadlock zu vermeiden
  145. timer_task = self._timer_task
  146. if timer_task and not timer_task.done():
  147. timer_task.cancel()
  148. try:
  149. await timer_task
  150. except asyncio.CancelledError:
  151. pass
  152. async with self._lock:
  153. if self._state not in (WindowState.OPEN, WindowState.DECIDING):
  154. return []
  155. self._state = WindowState.DECIDING
  156. candidates = self._candidates.copy()
  157. # Callback außerhalb des Locks ausführen
  158. if self.on_complete and candidates:
  159. try:
  160. await self.on_complete(candidates)
  161. except Exception:
  162. pass
  163. async with self._lock:
  164. self._state = WindowState.COMPLETED
  165. return candidates
  166. async def reset(self) -> None:
  167. """Setzt das Fenster zurück für neue Arbitration."""
  168. async with self._lock:
  169. if self._timer_task and not self._timer_task.done():
  170. self._timer_task.cancel()
  171. try:
  172. await self._timer_task
  173. except asyncio.CancelledError:
  174. pass
  175. self._state = WindowState.CLOSED
  176. self._candidates = []
  177. self._opened_at = None
  178. self._timer_task = None
  179. def get_candidate(self, satellite_id: str) -> ArbitrationCandidate | None:
  180. """Holt Kandidaten nach Satellite-ID."""
  181. for candidate in self._candidates:
  182. if candidate.satellite_id == satellite_id:
  183. return candidate
  184. return None
  185. def has_candidate(self, satellite_id: str) -> bool:
  186. """Prüft ob Satellite bereits Kandidat ist."""
  187. return any(c.satellite_id == satellite_id for c in self._candidates)
  188. async def _window_timer(self) -> None:
  189. """Timer-Task für automatisches Schließen."""
  190. try:
  191. # Minimale Wartezeit
  192. await asyncio.sleep(self.config.min_wait_ms / 1000)
  193. # Restliche Zeit
  194. remaining = (self.config.window_duration_ms - self.config.min_wait_ms) / 1000
  195. if remaining > 0:
  196. await asyncio.sleep(remaining)
  197. # Fenster schließen
  198. await self.close(reason="timeout")
  199. except asyncio.CancelledError:
  200. pass
  201. async def _close_early(self, reason: str) -> None:
  202. """Schließt Fenster vorzeitig."""
  203. # Kurz warten für eventuelle weitere Kandidaten
  204. await asyncio.sleep(self.config.min_wait_ms / 1000)
  205. await self.close(reason=reason)
  206. def __repr__(self) -> str:
  207. return (
  208. f"ArbitrationWindow("
  209. f"state={self._state.value}, "
  210. f"candidates={len(self._candidates)}, "
  211. f"duration={self.duration_ms:.0f}ms)"
  212. )