# trixy_core.arbitration Wakeword-Arbitration für Multi-Satellite-Entscheidung. Wenn mehrere Satellites gleichzeitig ein Wakeword erkennen, entscheidet das Arbitration-System, welcher Satellite die Konversation führen soll. ## Übersicht ```python from trixy_core.arbitration import ( # Candidate ArbitrationCandidate, CandidateState, # Window ArbitrationWindow, WindowConfig, WindowState, # Strategies ArbitrationStrategy, LoudestStrategy, ConfidenceStrategy, CombinedStrategy, ProximityStrategy, # Arbitrator WakewordArbitrator, ArbitrationConfig, ArbitrationResult, ) ``` --- ## Ablauf ``` ┌─────────────────────────────────────────────────────────────────┐ │ Wakeword-Arbitration │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ t=0ms Küche sendet WakewordDetected(audio_level=0.7) │ │ → Arbitration-Fenster öffnet (1000ms Timer) │ │ │ │ t=50ms Wohnzimmer sendet WakewordDetected(audio_level=0.85) │ │ → Kandidat wird hinzugefügt │ │ │ │ t=1000ms Timer abgelaufen │ │ → Strategy.select(candidates) → Wohnzimmer gewinnt │ │ → WakewordSelected an Wohnzimmer │ │ → WakewordAbort an Küche │ │ → Conversation-Session wird gestartet │ │ │ └─────────────────────────────────────────────────────────────────┘ ``` --- ## ArbitrationCandidate Repräsentiert einen Satellite, der ein Wakeword erkannt hat. ```python from trixy_core.arbitration import ArbitrationCandidate, CandidateState candidate = ArbitrationCandidate( satellite_id="living_room", room_id="wohnzimmer", alias="Wohnzimmer", wakeword_model="trixy", confidence=0.92, audio_level=0.85, buffer_samples=16000, ) # Eigenschaften print(candidate.satellite_id) # "living_room" print(candidate.audio_level) # 0.85 (normalisiert 0.0-1.0) print(candidate.confidence) # 0.92 (normalisiert 0.0-1.0) print(candidate.state) # CandidateState.PENDING print(candidate.age_ms) # Millisekunden seit Erkennung # Zustand print(candidate.is_pending) # True print(candidate.is_selected) # False print(candidate.is_rejected) # False # Zustandsänderung candidate.select() # Markiert als ausgewählt candidate.reject("Grund") # Markiert als abgelehnt # Score (von Strategy gesetzt) print(candidate.score) # 0.0-1.0 print(candidate.score_details) # {"audio_level": 0.85, ...} # Serialisierung data = candidate.to_dict() ``` ### CandidateState ```python from trixy_core.arbitration import CandidateState class CandidateState(Enum): PENDING = "pending" # Wartet auf Entscheidung SELECTED = "selected" # Wurde ausgewählt REJECTED = "rejected" # Wurde abgelehnt TIMEOUT = "timeout" # Zeitüberschreitung ``` --- ## ArbitrationWindow Zeitfenster für die Sammlung von Kandidaten. ```python from trixy_core.arbitration import ArbitrationWindow, WindowConfig config = WindowConfig( window_duration_ms=1000, # Gesamtdauer des Fensters min_wait_ms=100, # Minimale Wartezeit max_candidates=10, # Maximale Kandidaten immediate_threshold=0.95, # Sofortige Entscheidung bei diesem Wert ) window = ArbitrationWindow( config=config, on_complete=async_callback, # Wird bei Abschluss aufgerufen ) # Fenster öffnen await window.open() # Kandidaten hinzufügen candidate = ArbitrationCandidate(satellite_id="test", audio_level=0.8) await window.add_candidate(candidate) # Status print(window.is_open) # True print(window.candidate_count) # 1 print(window.duration_ms) # Zeit seit Öffnung print(window.remaining_ms) # Verbleibende Zeit # Kandidat abrufen c = window.get_candidate("test") has = window.has_candidate("test") # Manuell schließen candidates = await window.close(reason="manual") # Zurücksetzen await window.reset() ``` ### WindowState ```python from trixy_core.arbitration import WindowState class WindowState(Enum): CLOSED = "closed" # Kein aktives Fenster OPEN = "open" # Fenster offen, sammelt Kandidaten DECIDING = "deciding" # Entscheidung wird getroffen COMPLETED = "completed" # Entscheidung abgeschlossen ``` --- ## Strategien ### ArbitrationStrategy (Basis) ```python from trixy_core.arbitration import ArbitrationStrategy class MyStrategy(ArbitrationStrategy): @property def name(self) -> str: return "my_strategy" def calculate_score( self, candidate: ArbitrationCandidate, context: dict = None, ) -> float: return candidate.audio_level * 0.7 + candidate.confidence * 0.3 def select( self, candidates: list[ArbitrationCandidate], context: dict = None, ) -> ArbitrationCandidate | None: valid = self.filter_valid(candidates) if not valid: return None for c in valid: c.score = self.calculate_score(c, context) return max(valid, key=lambda c: c.score) ``` ### LoudestStrategy Wählt den Satellite mit dem höchsten Audio-Level. ```python from trixy_core.arbitration import LoudestStrategy strategy = LoudestStrategy(min_audio_level=0.1) candidates = [ ArbitrationCandidate(satellite_id="quiet", audio_level=0.5), ArbitrationCandidate(satellite_id="loud", audio_level=0.9), ] winner = strategy.select(candidates) # winner.satellite_id == "loud" ``` ### ConfidenceStrategy Wählt den Satellite mit der höchsten Wakeword-Confidence. ```python from trixy_core.arbitration import ConfidenceStrategy strategy = ConfidenceStrategy(min_confidence=0.5) candidates = [ ArbitrationCandidate(satellite_id="low", confidence=0.6), ArbitrationCandidate(satellite_id="high", confidence=0.95), ] winner = strategy.select(candidates) # winner.satellite_id == "high" ``` ### CombinedStrategy Kombiniert mehrere Faktoren mit Gewichtungen. ```python from trixy_core.arbitration import CombinedStrategy from trixy_core.arbitration.strategies.combined import CombinedWeights weights = CombinedWeights( audio_level=0.6, # 60% Audio-Level confidence=0.3, # 30% Confidence recency=0.1, # 10% Frühe Erkennung ) strategy = CombinedStrategy( weights=weights, min_audio_level=0.1, min_confidence=0.5, ) candidates = [ ArbitrationCandidate( satellite_id="balanced", audio_level=0.8, confidence=0.8, ), ArbitrationCandidate( satellite_id="loud_only", audio_level=0.95, confidence=0.5, ), ] winner = strategy.select(candidates) # "balanced" gewinnt mit höherem Gesamtscore ``` ### ProximityStrategy Berücksichtigt Raum-Relevanz und Kontext. ```python from trixy_core.arbitration import ProximityStrategy from trixy_core.arbitration.strategies.proximity import ( ProximityWeights, RoomRelevance, ) room_relevance = RoomRelevance( # Räume die für bestimmte Aktionen bevorzugt werden room_priorities={ "music": ["living_room", "bedroom"], "alarm": ["bedroom", "kitchen"], }, # Letzter aktiver Raum bekommt Bonus last_active_bonus=0.15, # Benachbarte Räume adjacent_rooms={ "living_room": ["hallway", "kitchen"], "bedroom": ["hallway", "bathroom"], }, adjacent_bonus=0.1, ) strategy = ProximityStrategy(room_relevance=room_relevance) context = { "last_active_room": "living_room", "last_active_satellite": "living_room_1", } candidates = [ ArbitrationCandidate( satellite_id="kitchen", room_id="kitchen", audio_level=0.8, ), ArbitrationCandidate( satellite_id="living", room_id="living_room", audio_level=0.75, ), ] winner = strategy.select(candidates, context) # "living" könnte gewinnen wegen last_active_bonus ``` --- ## WakewordArbitrator Hauptservice für Wakeword-Arbitration. ```python from trixy_core.arbitration import ( WakewordArbitrator, ArbitrationConfig, ArbitrationResult, ArbitrationCandidate, ) config = ArbitrationConfig( # Zeitfenster window_duration_ms=1000, min_wait_ms=100, max_candidates=10, # Schwellenwerte immediate_threshold=0.95, min_audio_level=0.1, min_confidence=0.5, # Strategie strategy="combined", # "loudest", "confidence", "combined", "proximity" # Combined-Strategie Gewichtungen audio_weight=0.6, confidence_weight=0.3, recency_weight=0.1, # Verhalten auto_start_conversation=True, notify_rejected=True, ) arbitrator = WakewordArbitrator(config) ``` ### Kandidaten hinzufügen ```python candidate = ArbitrationCandidate( satellite_id="living_room", room_id="wohnzimmer", audio_level=0.85, confidence=0.9, ) await arbitrator.add_candidate(candidate) ``` ### Entscheidungs-Callback ```python async def on_decision(result: ArbitrationResult) -> None: if result.success: print(f"Gewinner: {result.winner.satellite_id}") print(f"Conversation: {result.conversation_id}") print(f"Abgelehnt: {len(result.rejected)}") else: print("Keine gültigen Kandidaten") arbitrator.on_decision(on_decision) # Callback entfernen arbitrator.remove_decision_callback(on_decision) ``` ### Status und Kontrolle ```python # Status prüfen print(arbitrator.is_arbitrating) # Arbitration aktiv? print(arbitrator.current_candidates) # Aktuelle Kandidaten-Liste # Fenster-Status status = arbitrator.get_window_status() # { # "state": "open", # "candidates": 2, # "duration_ms": 500, # "remaining_ms": 500, # "arbitration_id": "arb_12345678", # } # Strategie ändern from trixy_core.arbitration import LoudestStrategy arbitrator.strategy = LoudestStrategy() # Aktuelle Arbitration abbrechen await arbitrator.cancel_current() # Statistiken stats = arbitrator.stats # { # "total_arbitrations": 10, # "successful": 9, # "single_candidate": 3, # "multiple_candidates": 7, # "average_candidates": 2.5, # "average_duration_ms": 950, # } ``` ### Als Service starten ```python # Mit Application arbitrator = WakewordArbitrator(config, application=app) await arbitrator.start() # Event-Handler wird automatisch registriert für "wakeword_detected" # ... await arbitrator.stop() ``` --- ## ArbitrationResult Ergebnis einer Arbitration. ```python from trixy_core.arbitration import ArbitrationResult # Felder result.success # True wenn Gewinner gefunden result.winner # ArbitrationCandidate oder None result.rejected # Liste abgelehnter Kandidaten result.conversation_id # Generierte Conversation-ID result.arbitration_id # Eindeutige Arbitration-ID result.started_at # Start-Zeitstempel result.decided_at # Entscheidungs-Zeitstempel result.duration_ms # Dauer in Millisekunden result.candidate_count # Anzahl Kandidaten result.strategy_used # Name der verwendeten Strategie result.close_reason # Grund für Fenster-Schließung # Serialisierung data = result.to_dict() ``` --- ## Integration ### Mit Event-System ```python from trixy_core.events import TrixyEvent class MyService: def __init__(self, arbitrator: WakewordArbitrator): self.arbitrator = arbitrator self.arbitrator.on_decision(self.handle_decision) async def handle_decision(self, result: ArbitrationResult) -> None: if result.success: # Konversation starten await self.start_conversation( satellite_id=result.winner.satellite_id, conversation_id=result.conversation_id, ) ``` ### In Plugins ```python from trixy_core.plugins import TrixyPlugin from trixy_core.arbitration import ArbitrationCandidate class MyPlugin(TrixyPlugin): @TrixyEvent(["wakeword_detected"]) async def on_wakeword(self, event_name: str, data: dict) -> None: # Arbitrator aus Application holen arbitrator = self.application.get_service("wakeword_arbitrator") candidate = ArbitrationCandidate( satellite_id=data["satellite_id"], room_id=data.get("room_id", ""), audio_level=data.get("audio_level", 0.0), confidence=data.get("confidence", 0.0), ) await arbitrator.add_candidate(candidate) ``` --- ## Konfigurationsbeispiele ### Standard (Combined) ```json { "arbitration": { "strategy": "combined", "window_duration_ms": 1000, "audio_weight": 0.6, "confidence_weight": 0.3, "recency_weight": 0.1 } } ``` ### Nur Audio-Level ```json { "arbitration": { "strategy": "loudest", "window_duration_ms": 800, "min_audio_level": 0.2 } } ``` ### Mit Raum-Relevanz ```json { "arbitration": { "strategy": "proximity", "window_duration_ms": 1200, "room_priorities": { "music": ["living_room", "bedroom"], "alarm": ["bedroom", "kitchen"] }, "adjacent_rooms": { "living_room": ["hallway", "kitchen"] } } } ```