arbitrator.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610
  1. # -*- coding: utf-8 -*-
  2. """
  3. Wakeword-Arbitrator Service.
  4. Zentraler Service für die Wakeword-Arbitration.
  5. Koordiniert das Zeitfenster, die Strategieauswahl und
  6. die Kommunikation mit Satellites.
  7. """
  8. import asyncio
  9. import uuid
  10. from dataclasses import dataclass, field
  11. from datetime import datetime
  12. from typing import Any, Callable, Awaitable, TYPE_CHECKING
  13. from trixy_core.arbitration.candidate import ArbitrationCandidate, CandidateState
  14. from trixy_core.arbitration.window import ArbitrationWindow, WindowConfig, WindowState
  15. from trixy_core.arbitration.strategies import (
  16. ArbitrationStrategy,
  17. LoudestStrategy,
  18. CombinedStrategy,
  19. )
  20. from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
  21. if TYPE_CHECKING:
  22. from trixy_core.satellite import Satellite
  23. from trixy_core.application import IApplication
  24. @dataclass
  25. class ArbitrationConfig:
  26. """Konfiguration für den Arbitrator."""
  27. # Zeitfenster
  28. window_duration_ms: int = 1000
  29. min_wait_ms: int = 100
  30. max_candidates: int = 10
  31. # Schwellenwerte
  32. immediate_threshold: float = 0.95
  33. min_audio_level: float = 0.1
  34. min_confidence: float = 0.5
  35. # Strategie
  36. strategy: str = "combined" # "loudest", "confidence", "combined", "proximity"
  37. # Combined-Strategie Gewichtungen
  38. audio_weight: float = 0.6
  39. confidence_weight: float = 0.3
  40. recency_weight: float = 0.1
  41. # Verhalten
  42. auto_start_conversation: bool = True
  43. notify_rejected: bool = True
  44. @dataclass
  45. class ArbitrationResult:
  46. """Ergebnis einer Arbitration."""
  47. # Ergebnis
  48. success: bool = False
  49. winner: ArbitrationCandidate | None = None
  50. rejected: list[ArbitrationCandidate] = field(default_factory=list)
  51. # Session
  52. conversation_id: str = ""
  53. arbitration_id: str = ""
  54. # Timing
  55. started_at: datetime = field(default_factory=datetime.now)
  56. decided_at: datetime | None = None
  57. duration_ms: float = 0.0
  58. # Details
  59. candidate_count: int = 0
  60. strategy_used: str = ""
  61. close_reason: str = ""
  62. def __post_init__(self) -> None:
  63. """Berechnet Dauer wenn decided_at gesetzt."""
  64. if self.decided_at:
  65. delta = self.decided_at - self.started_at
  66. self.duration_ms = delta.total_seconds() * 1000
  67. def to_dict(self) -> dict[str, Any]:
  68. """Konvertiert zu Dictionary."""
  69. return {
  70. "success": self.success,
  71. "winner": self.winner.to_dict() if self.winner else None,
  72. "rejected_count": len(self.rejected),
  73. "conversation_id": self.conversation_id,
  74. "arbitration_id": self.arbitration_id,
  75. "duration_ms": self.duration_ms,
  76. "candidate_count": self.candidate_count,
  77. "strategy_used": self.strategy_used,
  78. "close_reason": self.close_reason,
  79. }
  80. class WakewordArbitrator:
  81. """
  82. Wakeword-Arbitrator für Multi-Satellite-Entscheidung.
  83. Koordiniert die Auswahl eines Satellites wenn mehrere
  84. gleichzeitig ein Wakeword erkennen.
  85. Ablauf:
  86. 1. Erstes WakewordDetected öffnet Arbitration-Fenster
  87. 2. Weitere Erkennungen werden als Kandidaten hinzugefügt
  88. 3. Nach Ablauf des Fensters wird Strategie angewendet
  89. 4. Gewinner erhält WakewordSelected
  90. 5. Verlierer erhalten WakewordAbort
  91. Kann als eigenständige Komponente oder als Service verwendet werden.
  92. Beispiel:
  93. ```python
  94. # Standalone-Verwendung
  95. arbitrator = WakewordArbitrator(config)
  96. # Bei WakewordDetected
  97. candidate = ArbitrationCandidate(
  98. satellite_id="living_room",
  99. audio_level=0.85,
  100. confidence=0.9,
  101. )
  102. await arbitrator.add_candidate(candidate)
  103. # Callback für Entscheidung
  104. @arbitrator.on_decision
  105. async def handle_decision(result):
  106. print(f"Gewinner: {result.winner.satellite_id}")
  107. ```
  108. """
  109. # Service-Metadaten (für optionale Service-Integration)
  110. NAME = "wakeword_arbitrator"
  111. PRIORITY = 30 # MANAGER
  112. GROUP = "manager"
  113. DEPENDENCIES = ["event_manager", "satellite_manager"]
  114. def __init__(
  115. self,
  116. config: ArbitrationConfig | None = None,
  117. application: "IApplication | None" = None,
  118. ) -> None:
  119. """
  120. Initialisiert den Arbitrator.
  121. Args:
  122. config: Arbitrator-Konfiguration
  123. application: Anwendungsreferenz (optional)
  124. """
  125. self.config = config or ArbitrationConfig()
  126. self.application = application
  127. # Strategie erstellen
  128. self._strategy = self._create_strategy()
  129. # Aktuelles Fenster
  130. self._window: ArbitrationWindow | None = None
  131. self._current_arbitration_id: str = ""
  132. # Callbacks
  133. self._on_decision_callbacks: list[
  134. Callable[[ArbitrationResult], Awaitable[None]]
  135. ] = []
  136. # Statistiken
  137. self._stats = {
  138. "total_arbitrations": 0,
  139. "successful": 0,
  140. "single_candidate": 0,
  141. "multiple_candidates": 0,
  142. "average_candidates": 0.0,
  143. "average_duration_ms": 0.0,
  144. }
  145. # Lock für Thread-Sicherheit
  146. self._lock = asyncio.Lock()
  147. def _create_strategy(self) -> ArbitrationStrategy:
  148. """Erstellt die konfigurierte Strategie."""
  149. strategy_name = self.config.strategy.lower()
  150. if strategy_name == "loudest":
  151. return LoudestStrategy(
  152. min_audio_level=self.config.min_audio_level,
  153. )
  154. elif strategy_name == "confidence":
  155. from trixy_core.arbitration.strategies import ConfidenceStrategy
  156. return ConfidenceStrategy(
  157. min_confidence=self.config.min_confidence,
  158. )
  159. elif strategy_name == "proximity":
  160. from trixy_core.arbitration.strategies import ProximityStrategy
  161. return ProximityStrategy(
  162. min_audio_level=self.config.min_audio_level,
  163. min_confidence=self.config.min_confidence,
  164. )
  165. else: # combined (default)
  166. from trixy_core.arbitration.strategies.combined import CombinedWeights
  167. weights = CombinedWeights(
  168. audio_level=self.config.audio_weight,
  169. confidence=self.config.confidence_weight,
  170. recency=self.config.recency_weight,
  171. )
  172. return CombinedStrategy(
  173. weights=weights,
  174. min_audio_level=self.config.min_audio_level,
  175. min_confidence=self.config.min_confidence,
  176. )
  177. @property
  178. def strategy(self) -> ArbitrationStrategy:
  179. """Aktuelle Strategie."""
  180. return self._strategy
  181. @strategy.setter
  182. def strategy(self, strategy: ArbitrationStrategy) -> None:
  183. """Setzt neue Strategie."""
  184. self._strategy = strategy
  185. @property
  186. def is_arbitrating(self) -> bool:
  187. """Prüft ob Arbitration aktiv ist."""
  188. return self._window is not None and self._window.is_open
  189. @property
  190. def current_candidates(self) -> list[ArbitrationCandidate]:
  191. """Aktuelle Kandidaten."""
  192. if self._window:
  193. return self._window.candidates
  194. return []
  195. @property
  196. def stats(self) -> dict[str, Any]:
  197. """Statistiken."""
  198. return self._stats.copy()
  199. async def start(self) -> None:
  200. """
  201. Startet den Arbitrator.
  202. Registriert Event-Handler wenn Application verfügbar.
  203. """
  204. pinfo(
  205. f"WakewordArbitrator gestartet mit Strategie: {self._strategy.name}"
  206. )
  207. # Event-Handler registrieren wenn Application vorhanden
  208. if self.application and hasattr(self.application, "events"):
  209. self.application.events.register(
  210. "wakeword_detected",
  211. self._on_wakeword_detected,
  212. )
  213. async def stop(self) -> None:
  214. """
  215. Stoppt den Arbitrator.
  216. Bricht laufende Arbitration ab und entfernt Event-Handler.
  217. """
  218. # Laufende Arbitration abbrechen
  219. if self._window and self._window.is_open:
  220. await self._window.close(reason="service_shutdown")
  221. await self._window.reset()
  222. # Event-Handler entfernen
  223. if self.application and hasattr(self.application, "events"):
  224. self.application.events.unregister(
  225. "wakeword_detected",
  226. self._on_wakeword_detected,
  227. )
  228. pinfo("WakewordArbitrator gestoppt")
  229. async def _on_wakeword_detected(
  230. self,
  231. event_name: str,
  232. event_data: dict[str, Any],
  233. ) -> None:
  234. """
  235. Handler für wakeword_detected Event.
  236. Wird automatisch aufgerufen wenn ein Satellite
  237. ein Wakeword erkennt.
  238. """
  239. # Satellite-Daten extrahieren
  240. satellite_id = event_data.get("satellite_id", "")
  241. if not satellite_id:
  242. return
  243. # Kandidat erstellen
  244. candidate = ArbitrationCandidate(
  245. satellite_id=satellite_id,
  246. room_id=event_data.get("room_id", ""),
  247. alias=event_data.get("alias", ""),
  248. wakeword_model=event_data.get("wakeword_model", ""),
  249. confidence=event_data.get("confidence", 0.0),
  250. audio_level=event_data.get("audio_level", 0.0),
  251. buffer_samples=event_data.get("buffer_samples", 0),
  252. forced=event_data.get("forced", False),
  253. )
  254. # Zur Arbitration hinzufügen
  255. await self.add_candidate(candidate)
  256. async def add_candidate(
  257. self,
  258. candidate: ArbitrationCandidate,
  259. ) -> bool:
  260. """
  261. Fügt einen Kandidaten zur Arbitration hinzu.
  262. Öffnet bei Bedarf ein neues Arbitration-Fenster.
  263. Args:
  264. candidate: Der hinzuzufügende Kandidat
  265. Returns:
  266. True wenn erfolgreich hinzugefügt
  267. """
  268. async with self._lock:
  269. # Neues Fenster öffnen wenn nötig
  270. if not self._window or not self._window.is_open:
  271. self._window = ArbitrationWindow(
  272. config=WindowConfig(
  273. window_duration_ms=self.config.window_duration_ms,
  274. min_wait_ms=self.config.min_wait_ms,
  275. max_candidates=self.config.max_candidates,
  276. immediate_threshold=self.config.immediate_threshold,
  277. ),
  278. on_complete=self._on_window_complete,
  279. )
  280. self._current_arbitration_id = f"arb_{uuid.uuid4().hex[:8]}"
  281. await self._window.open()
  282. pdebug(
  283. f"Arbitration-Fenster geöffnet: {self._current_arbitration_id}"
  284. )
  285. # Kandidat hinzufügen
  286. success = await self._window.add_candidate(candidate)
  287. if success:
  288. pdebug(
  289. f"Kandidat hinzugefügt: {candidate.satellite_id} "
  290. f"(audio={candidate.audio_level:.2f}, "
  291. f"conf={candidate.confidence:.2f}"
  292. f"{', FORCED' if candidate.forced else ''})"
  293. )
  294. # Bei forciertem Kandidaten: Fenster sofort schliessen
  295. # (keine Wartezeit fuer andere Satellites)
  296. if success and candidate.forced and self._window and self._window.is_open:
  297. pinfo(
  298. f"Forcierte Arbitration: {candidate.satellite_id} "
  299. f"— Fenster sofort geschlossen"
  300. )
  301. await self._window.close(reason="forced")
  302. return success
  303. async def _on_window_complete(
  304. self,
  305. candidates: list[ArbitrationCandidate],
  306. ) -> None:
  307. """
  308. Callback wenn Arbitration-Fenster abgeschlossen.
  309. Führt Strategieauswahl durch und benachrichtigt Satellites.
  310. """
  311. if not candidates:
  312. return
  313. started_at = datetime.now()
  314. # Strategie anwenden
  315. context = await self._build_context()
  316. winner = self._strategy.select(candidates, context)
  317. # Ergebnis erstellen
  318. result = ArbitrationResult(
  319. success=winner is not None,
  320. winner=winner,
  321. rejected=[c for c in candidates if c != winner],
  322. arbitration_id=self._current_arbitration_id,
  323. started_at=started_at,
  324. decided_at=datetime.now(),
  325. candidate_count=len(candidates),
  326. strategy_used=self._strategy.name,
  327. close_reason=self._window.state.value if self._window else "unknown",
  328. )
  329. if winner:
  330. winner.select()
  331. result.conversation_id = f"conv_{uuid.uuid4().hex[:8]}"
  332. # Abgelehnte markieren
  333. for candidate in result.rejected:
  334. candidate.reject(reason="Anderer Satellite ausgewählt")
  335. # Statistiken aktualisieren
  336. self._update_stats(result)
  337. # Satellites benachrichtigen
  338. await self._notify_satellites(result)
  339. # Callbacks ausführen
  340. for callback in self._on_decision_callbacks:
  341. try:
  342. await callback(result)
  343. except Exception as e:
  344. perror(f"Callback-Fehler: {e}")
  345. # Conversation starten
  346. if result.success and self.config.auto_start_conversation:
  347. await self._start_conversation(result)
  348. # Fenster zurücksetzen
  349. if self._window:
  350. await self._window.reset()
  351. pinfo(
  352. f"Arbitration abgeschlossen: {result.arbitration_id} - "
  353. f"Gewinner: {winner.satellite_id if winner else 'keiner'} "
  354. f"({result.candidate_count} Kandidaten, {result.duration_ms:.0f}ms)"
  355. )
  356. async def _build_context(self) -> dict[str, Any]:
  357. """Baut Kontext für Strategieauswahl."""
  358. context: dict[str, Any] = {
  359. "window_duration_ms": self.config.window_duration_ms,
  360. }
  361. # Letzte aktive Conversation hinzufügen wenn verfügbar
  362. if self.application and hasattr(self.application, "conversation_manager"):
  363. conv_manager = self.application.conversation_manager
  364. last_conv = getattr(conv_manager, "last_conversation", None)
  365. if last_conv:
  366. context["last_active_room"] = getattr(last_conv, "room_id", "")
  367. context["last_active_satellite"] = getattr(
  368. last_conv, "satellite_id", ""
  369. )
  370. return context
  371. async def _notify_satellites(self, result: ArbitrationResult) -> None:
  372. """Benachrichtigt Satellites über Arbitration-Ergebnis."""
  373. if not self.application or not hasattr(self.application, "satellites"):
  374. return
  375. satellite_manager = self.application.satellites
  376. # Gewinner benachrichtigen
  377. if result.winner:
  378. satellite = satellite_manager.get(result.winner.satellite_id)
  379. if satellite:
  380. await self._send_selected(satellite, result)
  381. # Verlierer benachrichtigen
  382. if self.config.notify_rejected:
  383. for candidate in result.rejected:
  384. satellite = satellite_manager.get(candidate.satellite_id)
  385. if satellite:
  386. await self._send_abort(satellite, result)
  387. async def _send_selected(
  388. self,
  389. satellite: "Satellite",
  390. result: ArbitrationResult,
  391. ) -> None:
  392. """Sendet WakewordSelected an Gewinner."""
  393. try:
  394. from trixy_core.network.cmd import WakewordSelected
  395. msg = WakewordSelected(
  396. conversation_id=result.conversation_id,
  397. start_streaming=True,
  398. )
  399. await satellite.send_command(msg)
  400. pdebug(
  401. f"WakewordSelected gesendet an {satellite.id}"
  402. )
  403. except Exception as e:
  404. perror(
  405. f"Fehler beim Senden von WakewordSelected: {e}"
  406. )
  407. async def _send_abort(
  408. self,
  409. satellite: "Satellite",
  410. result: ArbitrationResult,
  411. ) -> None:
  412. """Sendet WakewordAbort an Verlierer."""
  413. try:
  414. from trixy_core.network.cmd import WakewordAbort
  415. msg = WakewordAbort(
  416. reason="Anderer Satellite ausgewählt",
  417. resume_detection=True,
  418. )
  419. await satellite.send_command(msg)
  420. pdebug(
  421. f"WakewordAbort gesendet an {satellite.id}"
  422. )
  423. except Exception as e:
  424. perror(
  425. f"Fehler beim Senden von WakewordAbort: {e}"
  426. )
  427. async def _start_conversation(self, result: ArbitrationResult) -> None:
  428. """Startet Conversation-Session für Gewinner."""
  429. if not result.winner:
  430. return
  431. # Event auslösen
  432. if self.application and hasattr(self.application, "events"):
  433. await self.application.events.emit(
  434. "conversation_started",
  435. {
  436. "satellite_id": result.winner.satellite_id,
  437. "conversation_id": result.conversation_id,
  438. "arbitration_id": result.arbitration_id,
  439. },
  440. )
  441. def _update_stats(self, result: ArbitrationResult) -> None:
  442. """Aktualisiert Statistiken."""
  443. self._stats["total_arbitrations"] += 1
  444. if result.success:
  445. self._stats["successful"] += 1
  446. if result.candidate_count == 1:
  447. self._stats["single_candidate"] += 1
  448. else:
  449. self._stats["multiple_candidates"] += 1
  450. # Durchschnitte aktualisieren
  451. total = self._stats["total_arbitrations"]
  452. self._stats["average_candidates"] = (
  453. (self._stats["average_candidates"] * (total - 1) + result.candidate_count)
  454. / total
  455. )
  456. self._stats["average_duration_ms"] = (
  457. (self._stats["average_duration_ms"] * (total - 1) + result.duration_ms)
  458. / total
  459. )
  460. def on_decision(
  461. self,
  462. callback: Callable[[ArbitrationResult], Awaitable[None]],
  463. ) -> None:
  464. """
  465. Registriert Callback für Arbitration-Entscheidungen.
  466. Args:
  467. callback: Async Callback-Funktion
  468. """
  469. self._on_decision_callbacks.append(callback)
  470. def remove_decision_callback(
  471. self,
  472. callback: Callable[[ArbitrationResult], Awaitable[None]],
  473. ) -> bool:
  474. """
  475. Entfernt Callback.
  476. Args:
  477. callback: Zu entfernender Callback
  478. Returns:
  479. True wenn entfernt
  480. """
  481. try:
  482. self._on_decision_callbacks.remove(callback)
  483. return True
  484. except ValueError:
  485. return False
  486. async def cancel_current(self) -> bool:
  487. """
  488. Bricht aktuelle Arbitration ab.
  489. Returns:
  490. True wenn abgebrochen
  491. """
  492. if not self._window or not self._window.is_open:
  493. return False
  494. await self._window.close(reason="cancelled")
  495. await self._window.reset()
  496. return True
  497. def get_window_status(self) -> dict[str, Any]:
  498. """Gibt Status des aktuellen Fensters zurück."""
  499. if not self._window:
  500. return {
  501. "state": "closed",
  502. "candidates": 0,
  503. }
  504. return {
  505. "state": self._window.state.value,
  506. "candidates": self._window.candidate_count,
  507. "duration_ms": self._window.duration_ms,
  508. "remaining_ms": self._window.remaining_ms,
  509. "arbitration_id": self._current_arbitration_id,
  510. }