service.py 50 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348
  1. # -*- coding: utf-8 -*-
  2. """
  3. Wakeword Service - Haupt-Service für Wakeword-Erkennung.
  4. Läuft auf Satellite und Standalone.
  5. Audio-Verarbeitung läuft in einem dedizierten Thread (nicht im asyncio-Loop),
  6. analog zu den OpenWakeWord-Beispielen. Nur Netzwerk-I/O und Event-Emission
  7. werden auf den asyncio-Loop dispatcht.
  8. """
  9. from dataclasses import dataclass, field
  10. from datetime import datetime
  11. from enum import Enum
  12. from typing import TYPE_CHECKING, Callable, Any, Awaitable
  13. import asyncio
  14. import queue
  15. import time
  16. import threading
  17. from trixy_core.service import IService, ServicePriority, ServiceGroup
  18. from trixy_core.wakeword.detector import (
  19. WakewordDetector,
  20. WakewordDetection,
  21. DetectorConfig,
  22. WakewordType,
  23. )
  24. from trixy_core.wakeword.audio_buffer import AudioBuffer, BufferConfig
  25. from trixy_core.wakeword.vad import VoiceActivityDetector, VADConfig, VADState
  26. from trixy_core.utils.debug import pinfo, pdebug, perror, pwarn
  27. if TYPE_CHECKING:
  28. from trixy_core.application import BaseApplication
  29. from trixy_core.audio.microphone import MicrophoneCapture
  30. from trixy_core.network.client_connection import ClientConnection
  31. class ServiceState(Enum):
  32. """Zustand des Wakeword-Service."""
  33. STOPPED = "stopped" # Service gestoppt
  34. LISTENING = "listening" # Wakeword-Erkennung aktiv
  35. PAUSED = "paused" # Pausiert (z.B. während Arbitration)
  36. RECORDING = "recording" # Aufnahme läuft
  37. WAITING_RESPONSE = "waiting" # Warte auf Antwort vom Server
  38. PROCESSING = "processing" # Verarbeitung läuft
  39. FOLLOW_UP = "follow_up" # Warte auf Rückfrage-Antwort
  40. @dataclass
  41. class WakewordServiceConfig:
  42. """Konfiguration für den Wakeword-Service."""
  43. # Detector-Konfiguration
  44. detector: DetectorConfig = field(default_factory=DetectorConfig)
  45. # Buffer-Konfiguration
  46. buffer: BufferConfig = field(default_factory=BufferConfig)
  47. # VAD-Konfiguration
  48. vad: VADConfig = field(default_factory=VADConfig)
  49. # Aufnahme-Konfiguration
  50. max_recording_seconds: float = 60.0
  51. silence_timeout_seconds: float = 3.0
  52. follow_up_timeout_seconds: float = 60.0
  53. # Arbitration
  54. arbitration_timeout_seconds: float = 2.0
  55. # Cooldown nach Wakeword-Erkennung (verhindert Mehrfach-Erkennung)
  56. wakeword_cooldown_seconds: float = 3.0
  57. # Standalone-Modus (keine Arbitration)
  58. standalone_mode: bool = False
  59. # Audio-Quelle
  60. audio_source: str | None = None # None = Standard-Mikrofon
  61. @dataclass
  62. class RecordingSession:
  63. """Eine Aufnahme-Session."""
  64. session_id: str
  65. wakeword_detection: WakewordDetection
  66. start_time: datetime
  67. end_time: datetime | None = None
  68. audio_data: bytes = b""
  69. state: ServiceState = ServiceState.RECORDING
  70. is_selected: bool = False
  71. # Für Rückfragen
  72. follow_up_count: int = 0
  73. last_activity: datetime = field(default_factory=datetime.now)
  74. class WakewordService(IService):
  75. """
  76. Wakeword Detection Service.
  77. Verantwortlich für:
  78. - Wakeword-Erkennung in Echtzeit
  79. - Audio-Aufnahme nach Wakeword
  80. - VAD für Sprach-Ende-Erkennung
  81. - Kommunikation mit Server (Arbitration)
  82. Audio-Verarbeitung läuft in einem dedizierten Thread, um den
  83. asyncio-Event-Loop nicht zu blockieren (wie in den OpenWakeWord-Beispielen).
  84. """
  85. PRIORITY = ServicePriority.CORE
  86. GROUP = ServiceGroup.AUDIO
  87. DEPENDENCIES = []
  88. def __init__(
  89. self,
  90. application: "BaseApplication",
  91. config: WakewordServiceConfig | None = None,
  92. ):
  93. """
  94. Initialisiert den Wakeword-Service.
  95. Args:
  96. application: Anwendungs-Instanz
  97. config: Service-Konfiguration
  98. """
  99. super().__init__(application)
  100. self._config = config or WakewordServiceConfig()
  101. # Komponenten
  102. self._detector = WakewordDetector(self._config.detector)
  103. self._audio_buffer = AudioBuffer(self._config.buffer)
  104. self._vad = VoiceActivityDetector(self._config.vad)
  105. # State
  106. self._state = ServiceState.STOPPED
  107. self._state_lock = threading.RLock()
  108. self._current_session: RecordingSession | None = None
  109. # Audio-Thread (dedizierter Thread für Echtzeit-Verarbeitung)
  110. self._audio_thread: threading.Thread | None = None
  111. self._audio_queue: queue.Queue[bytes] = queue.Queue(maxsize=50)
  112. self._stop_event = threading.Event()
  113. # Referenz auf den asyncio-Event-Loop (für Thread → Loop Kommunikation)
  114. self._loop: asyncio.AbstractEventLoop | None = None
  115. # Callbacks
  116. self._on_wakeword: list[Callable[[WakewordDetection], None]] = []
  117. self._on_recording_complete: list[Callable[[RecordingSession], None]] = []
  118. self._on_state_change: list[Callable[[ServiceState], None]] = []
  119. # Server-Kommunikation (wird von Client gesetzt)
  120. self._send_to_server: Callable[[str, dict], asyncio.Future] | None = None
  121. self._session_id_generator: Callable[[], str] | None = None
  122. # Mikrofon (Client-Modus)
  123. self._microphone: MicrophoneCapture | None = None
  124. # Audio-Streaming an Server (connection.send_audio)
  125. self._audio_streamer: Callable[[bytes], Awaitable[bool]] | None = None
  126. # Pending Future für Arbitration-Antwort
  127. self._arbitration_future: asyncio.Future | None = None
  128. # Cooldown für Wakeword-Erkennung (verhindert Mehrfach-Erkennung)
  129. self._last_wakeword_time: float = 0.0
  130. # Audio-Ducking: ALSA-Lautstaerke bei Wakeword reduzieren (Client-seitig)
  131. self._volume_handler: object | None = None # VolumeHandler Instanz
  132. self._ducking_volume_pct: int = 10 # Ziel-Lautstaerke beim Ducken
  133. self._pre_duck_volume: int | None = None # Gespeicherte Original-Lautstaerke
  134. self._is_ducked: bool = False
  135. # Externe Pause (Server/Plugin-gesteuert)
  136. self._externally_paused: bool = False
  137. # Server-gesteuerte Aufnahme
  138. self._server_recording_active: bool = False
  139. self._server_recording_id: str = ""
  140. self._server_recording_start_time: float = 0.0
  141. self._on_server_recording_stopped: Callable[[str, float], Awaitable[None]] | None = None
  142. @property
  143. def config(self) -> WakewordServiceConfig:
  144. """Gibt Konfiguration zurück."""
  145. return self._config
  146. @property
  147. def state(self) -> ServiceState:
  148. """Aktueller Zustand."""
  149. with self._state_lock:
  150. return self._state
  151. @property
  152. def externally_paused(self) -> bool:
  153. """Prüft ob die Erkennung extern pausiert ist."""
  154. return self._externally_paused
  155. @property
  156. def is_listening(self) -> bool:
  157. """Prüft ob Service auf Wakeword hört."""
  158. return self._state == ServiceState.LISTENING
  159. @property
  160. def is_recording(self) -> bool:
  161. """Prüft ob Aufnahme läuft."""
  162. return self._state in (ServiceState.RECORDING, ServiceState.FOLLOW_UP)
  163. @property
  164. def current_session(self) -> RecordingSession | None:
  165. """Aktuelle Aufnahme-Session."""
  166. return self._current_session
  167. def _set_state(self, new_state: ServiceState) -> None:
  168. """Setzt neuen Zustand und triggert Callbacks + Events."""
  169. with self._state_lock:
  170. if self._state == new_state:
  171. return
  172. old_state = self._state
  173. self._state = new_state
  174. # Callbacks außerhalb des Locks
  175. for callback in self._on_state_change:
  176. try:
  177. callback(new_state)
  178. except Exception:
  179. pass
  180. pdebug(f"WakewordService State: {old_state.value} -> {new_state.value}")
  181. # Events fuer wichtige State-Wechsel (async auf Event-Loop dispatchen)
  182. event_map = {
  183. ServiceState.LISTENING: "wakeword_listening",
  184. ServiceState.PAUSED: "wakeword_paused",
  185. ServiceState.RECORDING: "recording_started",
  186. ServiceState.PROCESSING: "recording_stopped",
  187. }
  188. event_name = event_map.get(new_state)
  189. if event_name:
  190. event_data = {"old_state": old_state.value, "new_state": new_state.value}
  191. if self._current_session:
  192. event_data["session_id"] = self._current_session.session_id
  193. if event_name == "recording_stopped" and self._current_session:
  194. elapsed = (datetime.now() - self._current_session.start_time).total_seconds()
  195. event_data["duration_seconds"] = elapsed
  196. self._emit_event(event_name, event_data)
  197. # === Audio-Ducking (ALSA, laeuft im Audio-Thread) ===
  198. def set_volume_handler(self, handler: object, ducking_pct: int = 10) -> None:
  199. """
  200. Setzt den VolumeHandler fuer Audio-Ducking.
  201. Wird vom Client bei der Initialisierung aufgerufen.
  202. Args:
  203. handler: VolumeHandler Instanz (hat get_volume/set_volume)
  204. ducking_pct: Ziel-Lautstaerke beim Ducken (0-100)
  205. """
  206. self._volume_handler = handler
  207. self._ducking_volume_pct = ducking_pct
  208. pdebug(f"WakewordService: Audio-Ducking aktiviert ({ducking_pct}%)")
  209. def _duck_audio(self) -> None:
  210. """
  211. Reduziert die ALSA-Lautstaerke sofort (synchron, im Audio-Thread).
  212. Nutzt amixer direkt statt den async VolumeHandler,
  213. weil wir im Audio-Thread sind und nicht warten koennen.
  214. """
  215. if self._is_ducked:
  216. return
  217. import subprocess
  218. import shutil
  219. if not shutil.which("amixer"):
  220. return
  221. try:
  222. # Aktuelle Lautstaerke lesen
  223. result = subprocess.run(
  224. ["amixer", "get", "Master"],
  225. capture_output=True, text=True, timeout=2,
  226. )
  227. if result.returncode == 0:
  228. for line in result.stdout.splitlines():
  229. if "%" in line:
  230. start = line.index("[") + 1
  231. end = line.index("%")
  232. self._pre_duck_volume = int(line[start:end])
  233. break
  234. # Lautstaerke reduzieren
  235. subprocess.run(
  236. ["amixer", "set", "Master", f"{self._ducking_volume_pct}%"],
  237. capture_output=True, timeout=2,
  238. )
  239. self._is_ducked = True
  240. pdebug(f"Audio-Duck: {self._pre_duck_volume}% → {self._ducking_volume_pct}%")
  241. except Exception as e:
  242. pdebug(f"Audio-Duck Fehler: {e}")
  243. def _unduck_audio(self) -> None:
  244. """Stellt die ALSA-Lautstaerke wieder her (synchron)."""
  245. if not self._is_ducked or self._pre_duck_volume is None:
  246. return
  247. import subprocess
  248. import shutil
  249. if not shutil.which("amixer"):
  250. return
  251. try:
  252. subprocess.run(
  253. ["amixer", "set", "Master", f"{self._pre_duck_volume}%"],
  254. capture_output=True, timeout=2,
  255. )
  256. pdebug(f"Audio-Unduck: {self._ducking_volume_pct}% → {self._pre_duck_volume}%")
  257. except Exception as e:
  258. pdebug(f"Audio-Unduck Fehler: {e}")
  259. self._pre_duck_volume = None
  260. self._is_ducked = False
  261. def _emit_event(self, event_name: str, data: dict) -> None:
  262. """Emittiert ein Event thread-safe auf dem asyncio-Loop."""
  263. events = getattr(self._application, "events", None)
  264. if events and self._loop and self._loop.is_running():
  265. self._schedule_async(events.emit(event_name, data))
  266. def _schedule_async(self, coro: Any) -> None:
  267. """
  268. Plant eine Coroutine thread-safe auf dem asyncio-Loop ein.
  269. Wird aus dem Audio-Thread aufgerufen für Netzwerk-I/O und Events.
  270. """
  271. if self._loop and self._loop.is_running():
  272. asyncio.run_coroutine_threadsafe(coro, self._loop)
  273. async def start(self) -> None:
  274. """Startet den Service (IService)."""
  275. await self.on_start()
  276. async def stop(self) -> None:
  277. """Stoppt den Service (IService)."""
  278. await self.on_stop()
  279. async def on_start(self) -> None:
  280. """Service-Start."""
  281. pinfo("Lade Wakeword-Modelle...")
  282. try:
  283. self._detector.load_models()
  284. pinfo(f"Wakeword-Modelle geladen: {self._detector.loaded_models}")
  285. except Exception as e:
  286. perror(f"Fehler beim Laden der Wakeword-Modelle: {e}")
  287. raise
  288. # Originale Aufnahme-Konfiguration merken (fuer recording_config_override Reset)
  289. self._original_silence_timeout = self._config.silence_timeout_seconds
  290. self._original_max_duration_ms = self._config.vad.max_duration_ms
  291. # Event-Handler fuer temporaere Konfigurationsaenderungen registrieren
  292. event_manager = getattr(self._application, "events", None)
  293. if event_manager:
  294. event_manager.register("recording_config_override", self._on_config_override)
  295. event_manager.register("wakeword_manual_trigger", self._on_manual_trigger)
  296. # Registriere Detector-Callback
  297. self._detector.on_detection(self._on_wakeword_detected)
  298. # VAD-Callbacks
  299. self._vad.on_silence(self._on_silence_detected)
  300. self._vad.on_timeout(self._on_recording_timeout)
  301. self._vad.on_no_speech(self._on_no_speech_detected)
  302. async def on_stop(self) -> None:
  303. """Service-Stop."""
  304. await self.stop_listening()
  305. self._detector.unload_models()
  306. async def start_listening(self) -> None:
  307. """Startet die Wakeword-Erkennung."""
  308. if self._state != ServiceState.STOPPED:
  309. return
  310. # Event-Loop-Referenz speichern
  311. self._loop = asyncio.get_running_loop()
  312. # Queue leeren (alte Frames von vorherigem Lauf verwerfen)
  313. self._drain_audio_queue()
  314. self._set_state(ServiceState.LISTENING)
  315. self._stop_event.clear()
  316. # Starte dedizierten Audio-Thread
  317. self._audio_thread = threading.Thread(
  318. target=self._audio_processing_loop,
  319. name="wakeword-audio",
  320. daemon=True,
  321. )
  322. self._audio_thread.start()
  323. # Mikrofon starten und direkt mit Queue verbinden
  324. if self._microphone:
  325. self._microphone.set_callback(self._mic_callback)
  326. self._microphone.start()
  327. pinfo("Wakeword-Erkennung gestartet")
  328. async def stop_listening(self) -> None:
  329. """Stoppt die Wakeword-Erkennung."""
  330. if self._microphone:
  331. self._microphone.stop()
  332. self._stop_event.set()
  333. if self._audio_thread and self._audio_thread.is_alive():
  334. self._audio_thread.join(timeout=2.0)
  335. self._audio_thread = None
  336. self._set_state(ServiceState.STOPPED)
  337. pinfo("Wakeword-Erkennung gestoppt")
  338. def _mic_callback(self, data: bytes) -> None:
  339. """
  340. Mikrofon-Callback (läuft in PortAudio-Thread).
  341. Ringbuffer-Semantik: Bei voller Queue wird der älteste Frame verworfen.
  342. Kein call_soon_threadsafe nötig — queue.Queue ist thread-safe.
  343. """
  344. try:
  345. self._audio_queue.put_nowait(data)
  346. except queue.Full:
  347. # Queue voll: ältesten Frame verwerfen, neuen einfügen
  348. try:
  349. self._audio_queue.get_nowait()
  350. except queue.Empty:
  351. pass
  352. try:
  353. self._audio_queue.put_nowait(data)
  354. except queue.Full:
  355. pass
  356. def pause(self) -> None:
  357. """Pausiert die Wakeword-Erkennung."""
  358. if self._state == ServiceState.LISTENING:
  359. self._set_state(ServiceState.PAUSED)
  360. def _drain_audio_queue(self) -> None:
  361. """Leert die Audio-Queue (verwirft aufgestaute Frames)."""
  362. drained = 0
  363. while True:
  364. try:
  365. self._audio_queue.get_nowait()
  366. drained += 1
  367. except queue.Empty:
  368. break
  369. if drained > 0:
  370. pdebug(f"Audio-Queue geleert: {drained} Frames verworfen")
  371. def resume(self) -> None:
  372. """Setzt die Wakeword-Erkennung nach interner Verarbeitung fort."""
  373. if self._state == ServiceState.STOPPED:
  374. return
  375. # Audio-Ducking: Lautstaerke wiederherstellen
  376. self._unduck_audio()
  377. # Queue leeren (alle Frames aus der Recording-Phase verwerfen)
  378. self._drain_audio_queue()
  379. self._detector.reset()
  380. if self._externally_paused:
  381. self._set_state(ServiceState.PAUSED)
  382. pdebug("Intern fortgesetzt, aber extern pausiert — bleibe in PAUSED")
  383. else:
  384. self._set_state(ServiceState.LISTENING)
  385. def external_pause(self) -> None:
  386. """Pausiert Wakeword-Erkennung extern (Server/Plugin)."""
  387. self._externally_paused = True
  388. if self._state == ServiceState.LISTENING:
  389. self._set_state(ServiceState.PAUSED)
  390. pinfo("Wakeword-Erkennung extern pausiert")
  391. def external_resume(self) -> None:
  392. """Setzt extern pausierte Wakeword-Erkennung fort."""
  393. self._externally_paused = False
  394. if self._state == ServiceState.PAUSED:
  395. self._detector.reset()
  396. self._set_state(ServiceState.LISTENING)
  397. pinfo("Wakeword-Erkennung extern fortgesetzt")
  398. async def external_stop(self) -> None:
  399. """Stoppt Wakeword-Erkennung komplett (Modelle entladen)."""
  400. self._externally_paused = False
  401. await self.stop_listening()
  402. pinfo("Wakeword-Erkennung extern gestoppt")
  403. async def external_start(self) -> None:
  404. """Startet Wakeword-Erkennung (Modelle laden + Listening)."""
  405. if self._state != ServiceState.STOPPED:
  406. pdebug("Wakeword-Service läuft bereits")
  407. return
  408. try:
  409. self._detector.load_models()
  410. pinfo(f"Wakeword-Modelle geladen: {self._detector.loaded_models}")
  411. except Exception as e:
  412. perror(f"Fehler beim Laden der Wakeword-Modelle: {e}")
  413. return
  414. self._detector.on_detection(self._on_wakeword_detected)
  415. await self.start_listening()
  416. if self._externally_paused:
  417. self._set_state(ServiceState.PAUSED)
  418. pinfo("Wakeword-Erkennung extern gestartet")
  419. async def _on_manual_trigger(self, event_name: str, event_data: dict) -> None:
  420. """
  421. Event-Handler fuer manuellen Wakeword-Trigger (z.B. HID Hook-Taste).
  422. Ueberspringt die Wakeword-Erkennung und geht direkt in die Aufnahme.
  423. Arbitration wird forciert (sofortige Auswahl, kein Zeitfenster).
  424. """
  425. await self.trigger_manual(
  426. device=event_data.get("device", "unknown"),
  427. forced=event_data.get("forced", True),
  428. )
  429. async def trigger_manual(self, device: str = "button", forced: bool = True) -> None:
  430. """
  431. Manueller Wakeword-Trigger (z.B. durch Tastendruck).
  432. Ueberspringt die Wakeword-Detection und startet direkt die Aufnahme.
  433. Mit forced=True wird die Arbitration sofort entschieden (kein Warten
  434. auf andere Satellites).
  435. Args:
  436. device: Name des ausloesenden Geraets
  437. forced: True = Arbitration sofort entscheiden (kein Fenster)
  438. """
  439. if self._state != ServiceState.LISTENING:
  440. pdebug(f"Manueller Trigger ignoriert — State: {self._state.value}")
  441. return
  442. # Cooldown pruefen
  443. now = time.monotonic()
  444. if now - self._last_wakeword_time < self._config.wakeword_cooldown_seconds:
  445. pdebug("Manueller Trigger ignoriert — Cooldown")
  446. return
  447. self._last_wakeword_time = now
  448. pinfo(f"Manueller Wakeword-Trigger ({device}, forced={forced})")
  449. # Audio-Ducking sofort
  450. self._duck_audio()
  451. # Synthetische Detection erstellen (kein echtes Wakeword noetig)
  452. detection = WakewordDetection(
  453. wakeword_type=WakewordType.CUSTOM,
  454. model_name=f"manual:{device}",
  455. confidence=1.0, # Maximale Confidence (manuell ausgeloest)
  456. audio_level=1.0, # Maximaler Audio-Level (fuer Arbitration-Gewinn)
  457. timestamp=datetime.now(),
  458. audio_chunks=[], # Kein Wakeword-Audio bei manuellem Trigger
  459. )
  460. # Pausiere Erkennung
  461. self._set_state(ServiceState.PAUSED)
  462. # Session erstellen
  463. session_id = self._generate_session_id()
  464. self._current_session = RecordingSession(
  465. session_id=session_id,
  466. wakeword_detection=detection,
  467. start_time=datetime.now(),
  468. )
  469. if self._config.standalone_mode:
  470. # Standalone: Direkt aufnehmen
  471. self._current_session.is_selected = True
  472. await self._start_recording()
  473. else:
  474. # Client: Arbitration mit Force-Flag
  475. await self._request_arbitration(detection, forced=forced)
  476. async def feed_audio(self, audio_data: bytes) -> None:
  477. """
  478. Füttert Audio-Daten in den Service (async-API für externen Gebrauch).
  479. Bei voller Queue wird der älteste Frame verworfen (Ringbuffer-Semantik).
  480. Args:
  481. audio_data: 16-bit PCM Audio (16kHz, Mono)
  482. """
  483. try:
  484. self._audio_queue.put_nowait(audio_data)
  485. except queue.Full:
  486. try:
  487. self._audio_queue.get_nowait()
  488. except queue.Empty:
  489. pass
  490. try:
  491. self._audio_queue.put_nowait(audio_data)
  492. except queue.Full:
  493. pass
  494. # === Audio-Thread (dedizierter Thread, kein asyncio) ===
  495. def _audio_processing_loop(self) -> None:
  496. """
  497. Haupt-Audio-Verarbeitungsschleife — läuft in eigenem Thread.
  498. Synchron wie in den OpenWakeWord-Beispielen:
  499. queue.get() blockiert bis Frame da → process_frame() → nächster get().
  500. Der Thread ist unabhängig vom asyncio-Loop und kann daher nie
  501. durch Event-Loop-Last verzögert werden.
  502. """
  503. while not self._stop_event.is_set():
  504. try:
  505. # Blockierend auf nächsten Frame warten (mit Timeout)
  506. try:
  507. audio_data = self._audio_queue.get(timeout=0.1)
  508. except queue.Empty:
  509. continue
  510. state = self._state
  511. if state in (ServiceState.LISTENING, ServiceState.PAUSED):
  512. # LISTENING: Nur den LETZTEN Frame verarbeiten.
  513. # Ältere Frames verwerfen — OpenWakeWord puffert intern
  514. # und braucht keine lückenlose Frame-Folge.
  515. latest = audio_data
  516. skipped = 0
  517. while True:
  518. try:
  519. latest = self._audio_queue.get_nowait()
  520. skipped += 1
  521. except queue.Empty:
  522. break
  523. self._process_audio_frame_sync(latest)
  524. elif state in (ServiceState.RECORDING, ServiceState.WAITING_RESPONSE,
  525. ServiceState.FOLLOW_UP):
  526. # RECORDING: ALLE Frames verarbeiten (VAD + Audio-Streaming).
  527. self._process_audio_frame_sync(audio_data)
  528. while True:
  529. try:
  530. frame = self._audio_queue.get_nowait()
  531. except queue.Empty:
  532. break
  533. self._process_audio_frame_sync(frame)
  534. # State könnte sich geändert haben (z.B. → PROCESSING)
  535. if self._state not in (ServiceState.RECORDING,
  536. ServiceState.WAITING_RESPONSE,
  537. ServiceState.FOLLOW_UP):
  538. break
  539. # STOPPED/PROCESSING: Frame verwerfen (implizit)
  540. except Exception as e:
  541. perror(f"Fehler in Audio-Thread: {e}")
  542. def _process_audio_frame_sync(self, audio_data: bytes) -> None:
  543. """
  544. Verarbeitet einen Audio-Frame synchron im Audio-Thread.
  545. CPU-intensive Arbeit (Detector, VAD) passiert hier direkt.
  546. Netzwerk-I/O wird auf den asyncio-Loop dispatcht.
  547. """
  548. state = self._state
  549. if state == ServiceState.LISTENING:
  550. # Wakeword-Erkennung (synchron — Detector ruft _on_wakeword_detected)
  551. self._audio_buffer.add_to_pre_buffer(audio_data)
  552. self._detector.process_frame(audio_data)
  553. # Server-gesteuerte Aufnahme: streamen via Event-Loop
  554. if self._server_recording_active and self._audio_streamer:
  555. self._schedule_async(self._audio_streamer(audio_data))
  556. elif state == ServiceState.WAITING_RESPONSE:
  557. # Audio buffern während auf Arbitration gewartet wird
  558. self._audio_buffer.add_chunk(audio_data)
  559. elif state == ServiceState.RECORDING:
  560. # Aufnahme: Buffer + VAD (synchron), Streaming via Event-Loop
  561. self._audio_buffer.add_chunk(audio_data)
  562. vad_state = self._vad.process_frame(audio_data)
  563. if self._audio_streamer:
  564. self._schedule_async(self._audio_streamer(audio_data))
  565. # Prüfe ob Aufnahme beendet werden soll.
  566. # WICHTIG: VADState.SILENCE bedeutet nur "gerade leise", NICHT
  567. # "Stille-Schwelle erreicht". Daher explizit silence_duration_ms prüfen.
  568. if vad_state == VADState.TIMEOUT:
  569. self._set_state(ServiceState.PROCESSING)
  570. self._schedule_async(self._finish_recording())
  571. elif vad_state == VADState.NO_SPEECH:
  572. self._set_state(ServiceState.PROCESSING)
  573. self._schedule_async(self._finish_recording_no_speech())
  574. elif (vad_state == VADState.SILENCE
  575. and self._vad.has_speech
  576. and self._vad.silence_duration_ms >= self._config.silence_timeout_seconds * 1000):
  577. self._set_state(ServiceState.PROCESSING)
  578. self._schedule_async(self._finish_recording())
  579. elif state == ServiceState.FOLLOW_UP:
  580. # Rückfrage-Aufnahme
  581. self._audio_buffer.add_chunk(audio_data)
  582. vad_state = self._vad.process_frame(audio_data)
  583. if vad_state == VADState.TIMEOUT:
  584. self._set_state(ServiceState.PROCESSING)
  585. self._schedule_async(self._finish_follow_up())
  586. elif (vad_state == VADState.SILENCE
  587. and self._vad.has_speech
  588. and self._vad.silence_duration_ms >= self._config.silence_timeout_seconds * 1000):
  589. self._set_state(ServiceState.PROCESSING)
  590. self._schedule_async(self._finish_follow_up())
  591. elif state == ServiceState.PAUSED:
  592. # Pre-Buffer weiter füllen
  593. self._audio_buffer.add_to_pre_buffer(audio_data)
  594. # Server-gesteuerte Aufnahme: auch im PAUSED-Zustand streamen
  595. if self._server_recording_active and self._audio_streamer:
  596. self._schedule_async(self._audio_streamer(audio_data))
  597. # === Wakeword-Erkennung und Session-Handling ===
  598. def _on_wakeword_detected(self, detection: WakewordDetection) -> None:
  599. """
  600. Callback wenn Wakeword erkannt wurde.
  601. Wird aus dem Audio-Thread (via Detector) aufgerufen.
  602. Args:
  603. detection: Erkennungs-Details
  604. """
  605. if self._state != ServiceState.LISTENING:
  606. return
  607. # Cooldown prüfen (verhindert Mehrfach-Erkennung desselben Wakewords)
  608. now = time.monotonic()
  609. if now - self._last_wakeword_time < self._config.wakeword_cooldown_seconds:
  610. pdebug(f"Wakeword ignoriert — Cooldown ({now - self._last_wakeword_time:.1f}s < {self._config.wakeword_cooldown_seconds}s)")
  611. return
  612. self._last_wakeword_time = now
  613. pinfo(
  614. f"Wakeword erkannt: {detection.model_name} "
  615. f"(Confidence: {detection.confidence:.2f}, Level: {detection.audio_level:.3f})"
  616. )
  617. # Server-gesteuerte Aufnahme auto-stoppen bei Wakeword
  618. if self._server_recording_active:
  619. recording_id = self._server_recording_id
  620. duration = self.stop_server_recording()
  621. if self._on_server_recording_stopped:
  622. self._schedule_async(self._on_server_recording_stopped(recording_id, duration))
  623. # Audio-Ducking: Lautstaerke SOFORT reduzieren (im Audio-Thread)
  624. self._duck_audio()
  625. # Pausiere Erkennung (sofort im Audio-Thread)
  626. self._set_state(ServiceState.PAUSED)
  627. # Async-Handling auf Event-Loop dispatchen
  628. self._schedule_async(self._handle_wakeword_detection(detection))
  629. # User-Callbacks (synchron)
  630. for callback in self._on_wakeword:
  631. try:
  632. callback(detection)
  633. except Exception:
  634. pass
  635. async def _handle_wakeword_detection(self, detection: WakewordDetection) -> None:
  636. """
  637. Behandelt Wakeword-Erkennung.
  638. Läuft auf dem asyncio-Loop (dispatcht vom Audio-Thread).
  639. Args:
  640. detection: Erkennungs-Details
  641. """
  642. # Generiere Session-ID
  643. session_id = self._generate_session_id()
  644. # Erstelle Session
  645. self._current_session = RecordingSession(
  646. session_id=session_id,
  647. wakeword_detection=detection,
  648. start_time=datetime.now(),
  649. )
  650. if self._config.standalone_mode:
  651. # Standalone: Immer ausgewählt
  652. self._current_session.is_selected = True
  653. await self._start_recording()
  654. else:
  655. # Client: Arbitration beim Server
  656. await self._request_arbitration(detection)
  657. async def _request_arbitration(
  658. self, detection: WakewordDetection, forced: bool = False,
  659. ) -> None:
  660. """
  661. Sendet Arbitration-Request an Server.
  662. Args:
  663. detection: Wakeword-Erkennung
  664. forced: True = Arbitration sofort entscheiden (kein Fenster warten)
  665. """
  666. if not self._send_to_server:
  667. perror("Keine Server-Verbindung konfiguriert")
  668. self.resume()
  669. return
  670. self._set_state(ServiceState.WAITING_RESPONSE)
  671. # Starte Aufnahme im Hintergrund (für flüssigen Dialog)
  672. self._audio_buffer.start_recording(include_pre_buffer=True)
  673. try:
  674. # Sende Request
  675. response = await asyncio.wait_for(
  676. self._send_to_server("wakeword_detected", {
  677. "session_id": self._current_session.session_id,
  678. "wakeword_type": detection.wakeword_type.value,
  679. "model_name": detection.model_name,
  680. "confidence": detection.confidence,
  681. "audio_level": detection.audio_level,
  682. "audio_chunks": [
  683. chunk.hex() for chunk in detection.audio_chunks
  684. ],
  685. "timestamp": detection.timestamp.isoformat(),
  686. "forced": forced,
  687. }),
  688. timeout=self._config.arbitration_timeout_seconds,
  689. )
  690. if response.get("selected"):
  691. # Wir wurden ausgewählt
  692. if not self._current_session:
  693. pdebug("Arbitration: Session bereits beendet (Race Condition)")
  694. self.resume()
  695. return
  696. self._current_session.is_selected = True
  697. self._current_session.session_id = response.get(
  698. "session_id",
  699. self._current_session.session_id,
  700. )
  701. await self._start_recording()
  702. else:
  703. # Nicht ausgewählt
  704. pdebug("Arbitration: Nicht ausgewählt")
  705. self._audio_buffer.clear()
  706. self._current_session = None
  707. self.resume()
  708. except asyncio.TimeoutError:
  709. pwarn("Arbitration-Timeout")
  710. self._audio_buffer.clear()
  711. self._current_session = None
  712. self.resume()
  713. except Exception as e:
  714. perror(f"Arbitration-Fehler: {e}")
  715. self._audio_buffer.clear()
  716. self._current_session = None
  717. self.resume()
  718. async def _start_recording(self) -> None:
  719. """Startet die Audio-Aufnahme."""
  720. self._set_state(ServiceState.RECORDING)
  721. # VAD starten
  722. self._vad.start()
  723. # Buffer starten (falls nicht schon geschehen)
  724. if not self._audio_buffer.is_recording:
  725. self._audio_buffer.start_recording(include_pre_buffer=True)
  726. # Gepuffertes Audio (Pre-Buffer + Wartezeit) an Server senden
  727. if self._audio_streamer and not self._config.standalone_mode:
  728. buffered = self._audio_buffer.get_data()
  729. if buffered:
  730. await self._audio_streamer(buffered)
  731. if self._current_session:
  732. pinfo(f"Aufnahme gestartet (Session: {self._current_session.session_id})")
  733. async def _finish_recording(self) -> None:
  734. """Beendet die Aufnahme und sendet Daten."""
  735. if not self._current_session:
  736. return
  737. # State wurde bereits im Audio-Thread auf PROCESSING gesetzt
  738. # Stoppe Aufnahme
  739. audio_data = self._audio_buffer.stop_recording()
  740. self._vad.stop()
  741. self._current_session.end_time = datetime.now()
  742. self._current_session.audio_data = audio_data
  743. pinfo(
  744. f"Aufnahme beendet: {len(audio_data)} Bytes, "
  745. f"{self._audio_buffer.duration_seconds:.1f}s"
  746. )
  747. # Sende an Server (oder verarbeite lokal)
  748. await self._send_recording()
  749. async def _finish_recording_no_speech(self) -> None:
  750. """Beendet die Aufnahme bei No-Speech (mögliche Fehlauslösung)."""
  751. if not self._current_session:
  752. return
  753. # State wurde bereits im Audio-Thread auf PROCESSING gesetzt
  754. # Stoppe Aufnahme
  755. self._audio_buffer.stop_recording()
  756. self._vad.stop()
  757. self._current_session.end_time = datetime.now()
  758. pinfo("Aufnahme abgebrochen — keine Sprache erkannt")
  759. if not self._config.standalone_mode and self._send_to_server:
  760. # Client-Modus: RecordingComplete mit speech_detected=False senden
  761. try:
  762. await self._send_to_server("recording_complete", {
  763. "session_id": self._current_session.session_id,
  764. "duration_seconds": self._audio_buffer.duration_seconds,
  765. "ended_by": "no_speech",
  766. "speech_detected": False,
  767. "peak_level": self._vad._peak_level,
  768. "vad_stats": self._vad.get_stats(),
  769. })
  770. except Exception as e:
  771. perror(f"Fehler beim Senden: {e}")
  772. # Session abschließen
  773. self._complete_session()
  774. async def _send_recording(self) -> None:
  775. """Sendet Aufnahme an Server oder verarbeitet lokal."""
  776. session = self._current_session
  777. if not session:
  778. self.resume()
  779. return
  780. if self._config.standalone_mode:
  781. # Lokal: Event emittieren
  782. await self._application.events.emit("raw_audio_received", {
  783. "session_id": session.session_id,
  784. "audio_data": session.audio_data,
  785. "wakeword_type": session.wakeword_detection.wakeword_type.value,
  786. "duration_seconds": self._audio_buffer.duration_seconds,
  787. })
  788. # Warte auf Verarbeitungs-Ergebnis
  789. await self._wait_for_processing_result()
  790. # Standalone: Session sofort abschließen
  791. self._complete_session()
  792. else:
  793. # Client: RecordingComplete Command senden (Audio wurde bereits gestreamt)
  794. if self._send_to_server:
  795. # ended_by aus VAD-State ableiten
  796. vad_state = self._vad.state
  797. if vad_state == VADState.SILENCE:
  798. ended_by = "silence"
  799. elif vad_state == VADState.TIMEOUT:
  800. ended_by = "timeout"
  801. else:
  802. ended_by = "unknown"
  803. try:
  804. await self._send_to_server("recording_complete", {
  805. "session_id": session.session_id,
  806. "duration_seconds": self._audio_buffer.duration_seconds,
  807. "ended_by": ended_by,
  808. "speech_detected": self._vad.has_speech,
  809. "peak_level": self._vad._peak_level,
  810. "vad_stats": self._vad.get_stats(),
  811. })
  812. except Exception as e:
  813. perror(f"Fehler beim Senden: {e}")
  814. # Client-Modus: NICHT sofort zurück zu LISTENING!
  815. # Bleibe in PROCESSING und warte auf ConversationEnd vom Server.
  816. # Timeout als Sicherheitsnetz, falls Server nie antwortet.
  817. pinfo("RecordingComplete gesendet — warte auf ConversationEnd vom Server")
  818. self._schedule_conversation_end_timeout()
  819. async def _wait_for_processing_result(self) -> None:
  820. """Wartet auf Verarbeitungs-Ergebnis (Standalone-Modus)."""
  821. if not self._current_session:
  822. return
  823. session_id = self._current_session.session_id
  824. result_future: asyncio.Future[dict] = asyncio.Future()
  825. async def on_processing_result(event_name: str, data) -> None:
  826. """Handler für processing_result Event."""
  827. if getattr(data, "session_id", None) == session_id:
  828. result_future.set_result({
  829. "success": getattr(data, "success", True),
  830. "text": getattr(data, "text", ""),
  831. "intent": getattr(data, "intent", ""),
  832. "response_text": getattr(data, "response_text", ""),
  833. "error": getattr(data, "error", ""),
  834. })
  835. # Event-Manager holen
  836. event_manager = getattr(self._application, "events", None)
  837. if not event_manager:
  838. pwarn("EventManager nicht verfügbar")
  839. return
  840. # Temporären Handler registrieren
  841. event_manager.register("processing_result", on_processing_result)
  842. try:
  843. # Auf Ergebnis warten (mit Timeout)
  844. timeout = self._config.follow_up_timeout_seconds
  845. result = await asyncio.wait_for(result_future, timeout=timeout)
  846. if result.get("error"):
  847. perror(f"Verarbeitungsfehler: {result['error']}")
  848. else:
  849. pinfo(f"Verarbeitung abgeschlossen: {result.get('intent', 'unbekannt')}")
  850. except asyncio.TimeoutError:
  851. pwarn(f"Timeout beim Warten auf Verarbeitungsergebnis ({timeout}s)")
  852. finally:
  853. # Handler wieder entfernen
  854. event_manager.unregister("processing_result", on_processing_result)
  855. async def _handle_follow_up(self, response: dict) -> None:
  856. """
  857. Behandelt Rückfrage vom Server.
  858. Args:
  859. response: Server-Antwort mit Rückfrage
  860. """
  861. if not self._current_session:
  862. return
  863. self._current_session.follow_up_count += 1
  864. self._current_session.last_activity = datetime.now()
  865. pinfo(f"Rückfrage #{self._current_session.follow_up_count}")
  866. # Starte neue Aufnahme für Antwort
  867. self._set_state(ServiceState.FOLLOW_UP)
  868. self._audio_buffer.clear()
  869. self._audio_buffer.start_recording(include_pre_buffer=True)
  870. self._vad.start()
  871. async def _finish_follow_up(self) -> None:
  872. """Beendet Follow-Up-Aufnahme."""
  873. if not self._current_session:
  874. return
  875. audio_data = self._audio_buffer.stop_recording()
  876. self._vad.stop()
  877. # Sende Follow-Up-Antwort
  878. if self._send_to_server:
  879. try:
  880. response = await self._send_to_server("follow_up_response", {
  881. "session_id": self._current_session.session_id,
  882. "audio_data": audio_data.hex(),
  883. "follow_up_number": self._current_session.follow_up_count,
  884. })
  885. # Noch eine Rückfrage?
  886. if response.get("follow_up"):
  887. await self._handle_follow_up(response)
  888. return
  889. except Exception as e:
  890. perror(f"Follow-Up-Fehler: {e}")
  891. # Session beenden
  892. self._complete_session()
  893. def _schedule_conversation_end_timeout(self) -> None:
  894. """Startet Timeout für ConversationEnd vom Server."""
  895. async def _timeout_check():
  896. await asyncio.sleep(15.0) # 15 Sekunden Timeout
  897. if self._state == ServiceState.PROCESSING and self._current_session:
  898. pwarn("ConversationEnd-Timeout — erzwinge Session-Ende")
  899. self._complete_session()
  900. self._schedule_async(_timeout_check())
  901. def _complete_session(self) -> None:
  902. """Schließt aktuelle Session ab."""
  903. session = self._current_session
  904. if session:
  905. # Callbacks
  906. for callback in self._on_recording_complete:
  907. try:
  908. callback(session)
  909. except Exception:
  910. pass
  911. self._current_session = None
  912. self._audio_buffer.clear()
  913. # Recording-Config-Override zuruecksetzen (falls temporaer geaendert)
  914. if hasattr(self, "_original_silence_timeout"):
  915. self._config.silence_timeout_seconds = self._original_silence_timeout
  916. if hasattr(self, "_original_max_duration_ms"):
  917. self._config.vad.max_duration_ms = self._original_max_duration_ms
  918. # Cooldown erneuern — verhindert Wakeword Re-Detection durch residuales
  919. # Audio im OpenWakeWord Sliding Window nach dem Resume
  920. self._last_wakeword_time = time.monotonic()
  921. # Audio-Queue leeren (verhindert Stau von alten Frames)
  922. self._drain_audio_queue()
  923. # Zurück zum Listening
  924. self.resume()
  925. async def _on_config_override(self, event_name: str, data) -> None:
  926. """
  927. Temporaere Konfigurationsaenderung fuer die naechste Aufnahme.
  928. Wird von Plugins emittiert um z.B. laengere Aufnahmen (Notizen)
  929. oder kuerzere Silence-Timeouts zu ermoeglichen.
  930. Die Werte werden in _complete_session() zurueckgesetzt.
  931. """
  932. silence = data.get("silence_timeout_seconds") if isinstance(data, dict) else getattr(data, "silence_timeout_seconds", None)
  933. max_recording = data.get("max_recording_seconds") if isinstance(data, dict) else getattr(data, "max_recording_seconds", None)
  934. if silence is not None:
  935. self._config.silence_timeout_seconds = float(silence)
  936. pdebug(f"Recording-Config Override: silence_timeout={silence}s")
  937. if max_recording is not None:
  938. self._config.vad.max_duration_ms = int(float(max_recording) * 1000)
  939. pdebug(f"Recording-Config Override: max_recording={max_recording}s")
  940. def _on_silence_detected(self) -> None:
  941. """Callback wenn Stille erkannt wurde."""
  942. pdebug("Stille erkannt")
  943. def _on_recording_timeout(self) -> None:
  944. """Callback bei Aufnahme-Timeout."""
  945. pinfo("Aufnahme-Timeout erreicht")
  946. def _on_no_speech_detected(self) -> None:
  947. """Callback wenn keine Sprache erkannt wurde."""
  948. pinfo("Keine Sprache erkannt — mögliche Fehlauslösung")
  949. def _generate_session_id(self) -> str:
  950. """Generiert eine Session-ID."""
  951. if self._session_id_generator:
  952. return self._session_id_generator()
  953. import uuid
  954. return f"ww-{uuid.uuid4().hex[:12]}"
  955. # === Public Callbacks ===
  956. def on_wakeword(self, callback: Callable[[WakewordDetection], None]) -> None:
  957. """Registriert Callback für Wakeword-Erkennung."""
  958. self._on_wakeword.append(callback)
  959. def on_recording_complete(
  960. self,
  961. callback: Callable[[RecordingSession], None],
  962. ) -> None:
  963. """Registriert Callback für abgeschlossene Aufnahme."""
  964. self._on_recording_complete.append(callback)
  965. def on_state_change(self, callback: Callable[[ServiceState], None]) -> None:
  966. """Registriert Callback für State-Änderungen."""
  967. self._on_state_change.append(callback)
  968. def set_server_connection(
  969. self,
  970. send_func: Callable[[str, dict], asyncio.Future],
  971. ) -> None:
  972. """
  973. Setzt Server-Kommunikationsfunktion.
  974. Args:
  975. send_func: Async-Funktion zum Senden an Server
  976. """
  977. self._send_to_server = send_func
  978. def set_session_id_generator(self, generator: Callable[[], str]) -> None:
  979. """Setzt Session-ID-Generator."""
  980. self._session_id_generator = generator
  981. def set_audio_streamer(self, streamer: Callable[[bytes], Awaitable[bool]]) -> None:
  982. """
  983. Setzt Audio-Streaming-Funktion (connection.send_audio).
  984. Args:
  985. streamer: Async-Funktion die Audio-Bytes an Server sendet
  986. """
  987. self._audio_streamer = streamer
  988. def setup_for_client(self, connection: "ClientConnection", microphone: "MicrophoneCapture") -> None:
  989. """
  990. Konfiguriert Service für Client-Betrieb mit Netzwerk.
  991. Args:
  992. connection: ClientConnection zum Server
  993. microphone: MicrophoneCapture-Instanz
  994. """
  995. self._microphone = microphone
  996. self.set_audio_streamer(connection.send_audio)
  997. # Future-basierte _send_to_server Funktion
  998. async def send_to_server(event_type: str, data: dict) -> dict:
  999. if event_type == "wakeword_detected":
  1000. from trixy_core.network.cmd.wakeword import WakewordDetected
  1001. cmd = WakewordDetected(
  1002. satellite_id=connection.satellite_id,
  1003. wakeword_type=data.get("wakeword_type", ""),
  1004. wakeword_model=data.get("model_name", ""),
  1005. confidence=data.get("confidence", 0.0),
  1006. audio_level=data.get("audio_level", 0.0),
  1007. audio_chunks=data.get("audio_chunks", []),
  1008. timestamp=data.get("timestamp", ""),
  1009. client_session_id=data.get("session_id", ""),
  1010. )
  1011. await connection.send_command(cmd)
  1012. # Future für Arbitration-Antwort
  1013. self._arbitration_future = asyncio.get_event_loop().create_future()
  1014. return await self._arbitration_future
  1015. elif event_type == "recording_complete":
  1016. from trixy_core.network.cmd.wakeword import RecordingComplete
  1017. cmd = RecordingComplete(
  1018. session_id=data.get("session_id", ""),
  1019. duration_seconds=data.get("duration_seconds", 0.0),
  1020. ended_by=data.get("ended_by", ""),
  1021. speech_detected=data.get("speech_detected", True),
  1022. peak_level=data.get("peak_level", 0.0),
  1023. vad_stats=data.get("vad_stats", {}),
  1024. )
  1025. await connection.send_command(cmd)
  1026. return {"success": True}
  1027. return {}
  1028. self.set_server_connection(send_to_server)
  1029. async def handle_wakeword_selected(self, data) -> None:
  1030. """
  1031. WakewordSelected vom Server → Future auflösen.
  1032. Args:
  1033. data: WakewordSelected-Daten
  1034. """
  1035. if self._arbitration_future and not self._arbitration_future.done():
  1036. session_id = getattr(data, "session_id", "") or ""
  1037. conversation_id = getattr(data, "conversation_id", "") or ""
  1038. if isinstance(data, dict):
  1039. session_id = data.get("session_id", "")
  1040. conversation_id = data.get("conversation_id", "")
  1041. self._arbitration_future.set_result({
  1042. "selected": True,
  1043. "session_id": session_id or conversation_id,
  1044. })
  1045. async def handle_wakeword_abort(self, data) -> None:
  1046. """
  1047. WakewordAbort vom Server → Future auflösen.
  1048. Args:
  1049. data: WakewordAbort-Daten
  1050. """
  1051. if self._arbitration_future and not self._arbitration_future.done():
  1052. self._arbitration_future.set_result({"selected": False})
  1053. # Cooldown-Timestamp aktualisieren damit nach Abort keine sofortige Re-Detection
  1054. self._last_wakeword_time = time.monotonic()
  1055. async def handle_conversation_end(self) -> None:
  1056. """ConversationEnd → Aufnahme beenden, zurück zu LISTENING."""
  1057. if self._state in (ServiceState.RECORDING, ServiceState.FOLLOW_UP):
  1058. self._vad.stop()
  1059. self._audio_buffer.stop_recording()
  1060. if self._state in (ServiceState.RECORDING, ServiceState.FOLLOW_UP,
  1061. ServiceState.PROCESSING, ServiceState.WAITING_RESPONSE):
  1062. pinfo("ConversationEnd empfangen — zurück zu LISTENING")
  1063. self._complete_session()
  1064. # === Server-gesteuerte Aufnahme ===
  1065. def start_server_recording(self, recording_id: str) -> None:
  1066. """Aktiviert Audio-Streaming im LISTENING-Zustand."""
  1067. self._server_recording_active = True
  1068. self._server_recording_id = recording_id
  1069. self._server_recording_start_time = time.monotonic()
  1070. pdebug(f"Server-Recording gestartet: {recording_id}")
  1071. def stop_server_recording(self) -> float:
  1072. """Beendet Audio-Streaming. Gibt Dauer zurück."""
  1073. duration = time.monotonic() - self._server_recording_start_time if self._server_recording_active else 0.0
  1074. self._server_recording_active = False
  1075. self._server_recording_id = ""
  1076. self._server_recording_start_time = 0.0
  1077. pdebug(f"Server-Recording gestoppt: {duration:.1f}s")
  1078. return duration
  1079. def set_server_recording_stopped_callback(
  1080. self, cb: Callable[[str, float], Awaitable[None]]
  1081. ) -> None:
  1082. """Setzt Callback für Wakeword-getriggertes Stop."""
  1083. self._on_server_recording_stopped = cb
  1084. def get_stats(self) -> dict:
  1085. """Gibt Statistiken zurück."""
  1086. return {
  1087. "state": self._state.value,
  1088. "detector": self._detector.stats,
  1089. "buffer": self._audio_buffer.get_stats(),
  1090. "vad": self._vad.get_stats() if self._vad.is_active else {},
  1091. "session": {
  1092. "id": self._current_session.session_id if self._current_session else None,
  1093. "follow_up_count": self._current_session.follow_up_count if self._current_session else 0,
  1094. },
  1095. }