service.py 52 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379
  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: Buffer + VAD + Streaming (wie RECORDING)
  581. self._audio_buffer.add_chunk(audio_data)
  582. vad_state = self._vad.process_frame(audio_data)
  583. # Audio an Server streamen (gleich wie bei RECORDING)
  584. if self._audio_streamer:
  585. self._schedule_async(self._audio_streamer(audio_data))
  586. if vad_state == VADState.TIMEOUT:
  587. self._set_state(ServiceState.PROCESSING)
  588. self._schedule_async(self._finish_follow_up())
  589. elif (vad_state == VADState.SILENCE
  590. and self._vad.has_speech
  591. and self._vad.silence_duration_ms >= self._config.silence_timeout_seconds * 1000):
  592. self._set_state(ServiceState.PROCESSING)
  593. self._schedule_async(self._finish_follow_up())
  594. elif state == ServiceState.PAUSED:
  595. # Pre-Buffer weiter füllen
  596. self._audio_buffer.add_to_pre_buffer(audio_data)
  597. # Server-gesteuerte Aufnahme: auch im PAUSED-Zustand streamen
  598. if self._server_recording_active and self._audio_streamer:
  599. self._schedule_async(self._audio_streamer(audio_data))
  600. # === Wakeword-Erkennung und Session-Handling ===
  601. def _on_wakeword_detected(self, detection: WakewordDetection) -> None:
  602. """
  603. Callback wenn Wakeword erkannt wurde.
  604. Wird aus dem Audio-Thread (via Detector) aufgerufen.
  605. Args:
  606. detection: Erkennungs-Details
  607. """
  608. if self._state != ServiceState.LISTENING:
  609. return
  610. # Cooldown prüfen (verhindert Mehrfach-Erkennung desselben Wakewords)
  611. now = time.monotonic()
  612. if now - self._last_wakeword_time < self._config.wakeword_cooldown_seconds:
  613. pdebug(f"Wakeword ignoriert — Cooldown ({now - self._last_wakeword_time:.1f}s < {self._config.wakeword_cooldown_seconds}s)")
  614. return
  615. self._last_wakeword_time = now
  616. pinfo(
  617. f"Wakeword erkannt: {detection.model_name} "
  618. f"(Confidence: {detection.confidence:.2f}, Level: {detection.audio_level:.3f})"
  619. )
  620. # Server-gesteuerte Aufnahme auto-stoppen bei Wakeword
  621. if self._server_recording_active:
  622. recording_id = self._server_recording_id
  623. duration = self.stop_server_recording()
  624. if self._on_server_recording_stopped:
  625. self._schedule_async(self._on_server_recording_stopped(recording_id, duration))
  626. # Audio-Ducking: Lautstaerke SOFORT reduzieren (im Audio-Thread)
  627. self._duck_audio()
  628. # Pausiere Erkennung (sofort im Audio-Thread)
  629. self._set_state(ServiceState.PAUSED)
  630. # Async-Handling auf Event-Loop dispatchen
  631. self._schedule_async(self._handle_wakeword_detection(detection))
  632. # User-Callbacks (synchron)
  633. for callback in self._on_wakeword:
  634. try:
  635. callback(detection)
  636. except Exception:
  637. pass
  638. async def _handle_wakeword_detection(self, detection: WakewordDetection) -> None:
  639. """
  640. Behandelt Wakeword-Erkennung.
  641. Läuft auf dem asyncio-Loop (dispatcht vom Audio-Thread).
  642. Args:
  643. detection: Erkennungs-Details
  644. """
  645. # Generiere Session-ID
  646. session_id = self._generate_session_id()
  647. # Erstelle Session
  648. self._current_session = RecordingSession(
  649. session_id=session_id,
  650. wakeword_detection=detection,
  651. start_time=datetime.now(),
  652. )
  653. if self._config.standalone_mode:
  654. # Standalone: Immer ausgewählt
  655. self._current_session.is_selected = True
  656. await self._start_recording()
  657. else:
  658. # Client: Arbitration beim Server
  659. await self._request_arbitration(detection)
  660. async def _request_arbitration(
  661. self, detection: WakewordDetection, forced: bool = False,
  662. ) -> None:
  663. """
  664. Sendet Arbitration-Request an Server.
  665. Args:
  666. detection: Wakeword-Erkennung
  667. forced: True = Arbitration sofort entscheiden (kein Fenster warten)
  668. """
  669. if not self._send_to_server:
  670. perror("Keine Server-Verbindung konfiguriert")
  671. self.resume()
  672. return
  673. self._set_state(ServiceState.WAITING_RESPONSE)
  674. # Starte Aufnahme im Hintergrund (für flüssigen Dialog)
  675. self._audio_buffer.start_recording(include_pre_buffer=True)
  676. try:
  677. # Sende Request
  678. response = await asyncio.wait_for(
  679. self._send_to_server("wakeword_detected", {
  680. "session_id": self._current_session.session_id,
  681. "wakeword_type": detection.wakeword_type.value,
  682. "model_name": detection.model_name,
  683. "confidence": detection.confidence,
  684. "audio_level": detection.audio_level,
  685. "audio_chunks": [
  686. chunk.hex() for chunk in detection.audio_chunks
  687. ],
  688. "timestamp": detection.timestamp.isoformat(),
  689. "forced": forced,
  690. }),
  691. timeout=self._config.arbitration_timeout_seconds,
  692. )
  693. if response.get("selected"):
  694. # Wir wurden ausgewählt
  695. if not self._current_session:
  696. pdebug("Arbitration: Session bereits beendet (Race Condition)")
  697. self.resume()
  698. return
  699. self._current_session.is_selected = True
  700. self._current_session.session_id = response.get(
  701. "session_id",
  702. self._current_session.session_id,
  703. )
  704. await self._start_recording()
  705. else:
  706. # Nicht ausgewählt
  707. pdebug("Arbitration: Nicht ausgewählt")
  708. self._audio_buffer.clear()
  709. self._current_session = None
  710. self.resume()
  711. except asyncio.TimeoutError:
  712. pwarn("Arbitration-Timeout")
  713. self._audio_buffer.clear()
  714. self._current_session = None
  715. self.resume()
  716. except Exception as e:
  717. perror(f"Arbitration-Fehler: {e}")
  718. self._audio_buffer.clear()
  719. self._current_session = None
  720. self.resume()
  721. async def _start_recording(self) -> None:
  722. """Startet die Audio-Aufnahme."""
  723. self._set_state(ServiceState.RECORDING)
  724. # VAD starten
  725. self._vad.start()
  726. # Buffer starten (falls nicht schon geschehen)
  727. if not self._audio_buffer.is_recording:
  728. self._audio_buffer.start_recording(include_pre_buffer=True)
  729. # Gepuffertes Audio (Pre-Buffer + Wartezeit) an Server senden
  730. if self._audio_streamer and not self._config.standalone_mode:
  731. buffered = self._audio_buffer.get_data()
  732. if buffered:
  733. await self._audio_streamer(buffered)
  734. if self._current_session:
  735. pinfo(f"Aufnahme gestartet (Session: {self._current_session.session_id})")
  736. async def _finish_recording(self) -> None:
  737. """Beendet die Aufnahme und sendet Daten."""
  738. if not self._current_session:
  739. return
  740. # State wurde bereits im Audio-Thread auf PROCESSING gesetzt
  741. # Stoppe Aufnahme
  742. audio_data = self._audio_buffer.stop_recording()
  743. self._vad.stop()
  744. self._current_session.end_time = datetime.now()
  745. self._current_session.audio_data = audio_data
  746. pinfo(
  747. f"Aufnahme beendet: {len(audio_data)} Bytes, "
  748. f"{self._audio_buffer.duration_seconds:.1f}s"
  749. )
  750. # Sende an Server (oder verarbeite lokal)
  751. await self._send_recording()
  752. async def _finish_recording_no_speech(self) -> None:
  753. """Beendet die Aufnahme bei No-Speech (mögliche Fehlauslösung)."""
  754. if not self._current_session:
  755. return
  756. # State wurde bereits im Audio-Thread auf PROCESSING gesetzt
  757. # Stoppe Aufnahme
  758. self._audio_buffer.stop_recording()
  759. self._vad.stop()
  760. self._current_session.end_time = datetime.now()
  761. pinfo("Aufnahme abgebrochen — keine Sprache erkannt")
  762. if not self._config.standalone_mode and self._send_to_server:
  763. # Client-Modus: RecordingComplete mit speech_detected=False senden
  764. try:
  765. await self._send_to_server("recording_complete", {
  766. "session_id": self._current_session.session_id,
  767. "duration_seconds": self._audio_buffer.duration_seconds,
  768. "ended_by": "no_speech",
  769. "speech_detected": False,
  770. "peak_level": self._vad._peak_level,
  771. "vad_stats": self._vad.get_stats(),
  772. })
  773. except Exception as e:
  774. perror(f"Fehler beim Senden: {e}")
  775. # Session abschließen
  776. self._complete_session()
  777. async def _send_recording(self) -> None:
  778. """Sendet Aufnahme an Server oder verarbeitet lokal."""
  779. session = self._current_session
  780. if not session:
  781. self.resume()
  782. return
  783. if self._config.standalone_mode:
  784. # Lokal: Event emittieren
  785. await self._application.events.emit("raw_audio_received", {
  786. "session_id": session.session_id,
  787. "audio_data": session.audio_data,
  788. "wakeword_type": session.wakeword_detection.wakeword_type.value,
  789. "duration_seconds": self._audio_buffer.duration_seconds,
  790. })
  791. # Warte auf Verarbeitungs-Ergebnis
  792. await self._wait_for_processing_result()
  793. # Standalone: Session sofort abschließen
  794. self._complete_session()
  795. else:
  796. # Client: RecordingComplete Command senden (Audio wurde bereits gestreamt)
  797. if self._send_to_server:
  798. # ended_by aus VAD-State ableiten
  799. vad_state = self._vad.state
  800. if vad_state == VADState.SILENCE:
  801. ended_by = "silence"
  802. elif vad_state == VADState.TIMEOUT:
  803. ended_by = "timeout"
  804. else:
  805. ended_by = "unknown"
  806. try:
  807. await self._send_to_server("recording_complete", {
  808. "session_id": session.session_id,
  809. "duration_seconds": self._audio_buffer.duration_seconds,
  810. "ended_by": ended_by,
  811. "speech_detected": self._vad.has_speech,
  812. "peak_level": self._vad._peak_level,
  813. "vad_stats": self._vad.get_stats(),
  814. })
  815. except Exception as e:
  816. perror(f"Fehler beim Senden: {e}")
  817. # Audio-Ducking: Lautstaerke SOFORT wiederherstellen
  818. # BEVOR die TTS-Antwort vom Server abgespielt wird.
  819. # Sonst ist die TTS-Ausgabe kaum hoerbar.
  820. self._unduck_audio()
  821. # Client-Modus: NICHT sofort zurück zu LISTENING!
  822. # Bleibe in PROCESSING und warte auf ConversationEnd vom Server.
  823. # Timeout als Sicherheitsnetz, falls Server nie antwortet.
  824. pinfo("RecordingComplete gesendet — warte auf ConversationEnd vom Server")
  825. self._schedule_conversation_end_timeout()
  826. async def _wait_for_processing_result(self) -> None:
  827. """Wartet auf Verarbeitungs-Ergebnis (Standalone-Modus)."""
  828. if not self._current_session:
  829. return
  830. session_id = self._current_session.session_id
  831. result_future: asyncio.Future[dict] = asyncio.Future()
  832. async def on_processing_result(event_name: str, data) -> None:
  833. """Handler für processing_result Event."""
  834. if getattr(data, "session_id", None) == session_id:
  835. result_future.set_result({
  836. "success": getattr(data, "success", True),
  837. "text": getattr(data, "text", ""),
  838. "intent": getattr(data, "intent", ""),
  839. "response_text": getattr(data, "response_text", ""),
  840. "error": getattr(data, "error", ""),
  841. })
  842. # Event-Manager holen
  843. event_manager = getattr(self._application, "events", None)
  844. if not event_manager:
  845. pwarn("EventManager nicht verfügbar")
  846. return
  847. # Temporären Handler registrieren
  848. event_manager.register("processing_result", on_processing_result)
  849. try:
  850. # Auf Ergebnis warten (mit Timeout)
  851. timeout = self._config.follow_up_timeout_seconds
  852. result = await asyncio.wait_for(result_future, timeout=timeout)
  853. if result.get("error"):
  854. perror(f"Verarbeitungsfehler: {result['error']}")
  855. else:
  856. pinfo(f"Verarbeitung abgeschlossen: {result.get('intent', 'unbekannt')}")
  857. except asyncio.TimeoutError:
  858. pwarn(f"Timeout beim Warten auf Verarbeitungsergebnis ({timeout}s)")
  859. finally:
  860. # Handler wieder entfernen
  861. event_manager.unregister("processing_result", on_processing_result)
  862. async def _handle_follow_up(self, response: dict) -> None:
  863. """
  864. Behandelt Rückfrage vom Server.
  865. Args:
  866. response: Server-Antwort mit Rückfrage
  867. """
  868. pinfo(f"_handle_follow_up aufgerufen (state={self._state}, session={'ja' if self._current_session else 'nein'})")
  869. if not self._current_session:
  870. pwarn("Follow-Up abgebrochen: keine aktive Session")
  871. return
  872. self._current_session.follow_up_count += 1
  873. self._current_session.last_activity = datetime.now()
  874. pinfo(f"Rückfrage #{self._current_session.follow_up_count}")
  875. # Starte neue Aufnahme für Antwort
  876. self._set_state(ServiceState.FOLLOW_UP)
  877. self._audio_buffer.clear()
  878. self._audio_buffer.start_recording(include_pre_buffer=True)
  879. self._vad.start()
  880. async def _finish_follow_up(self) -> None:
  881. """Beendet Follow-Up-Aufnahme und sendet RecordingComplete."""
  882. if not self._current_session:
  883. return
  884. audio_data = self._audio_buffer.stop_recording()
  885. self._vad.stop()
  886. duration = len(audio_data) / (16000 * 2) # 16kHz, 16-bit
  887. pinfo(f"Follow-Up Aufnahme beendet: {len(audio_data)} Bytes, {duration:.1f}s")
  888. # Audio-Ducking: Lautstaerke wiederherstellen vor TTS
  889. self._unduck_audio()
  890. # Im Client-Modus: RecordingComplete senden (Audio wurde bereits gestreamt)
  891. if self._connection:
  892. try:
  893. from trixy_core.network.cmd.wakeword import RecordingComplete
  894. cmd = RecordingComplete(
  895. session_id=self._current_session.session_id,
  896. speech_detected=self._vad.has_speech,
  897. duration_seconds=duration,
  898. audio_level=self._vad._peak_level if hasattr(self._vad, "_peak_level") else 0.0,
  899. )
  900. await self._connection.send_message(cmd)
  901. pinfo("Follow-Up RecordingComplete gesendet — warte auf Server")
  902. self._schedule_conversation_end_timeout()
  903. except Exception as e:
  904. perror(f"Follow-Up RecordingComplete Fehler: {e}")
  905. self._complete_session()
  906. return
  907. # Standalone-Modus: wie bisher
  908. if self._send_to_server:
  909. try:
  910. response = await self._send_to_server("follow_up_response", {
  911. "session_id": self._current_session.session_id,
  912. "audio_data": audio_data.hex(),
  913. "follow_up_number": self._current_session.follow_up_count,
  914. })
  915. if response.get("follow_up"):
  916. await self._handle_follow_up(response)
  917. return
  918. except Exception as e:
  919. perror(f"Follow-Up-Fehler: {e}")
  920. self._complete_session()
  921. def _schedule_conversation_end_timeout(self) -> None:
  922. """Startet Timeout für ConversationEnd vom Server."""
  923. async def _timeout_check():
  924. await asyncio.sleep(15.0) # 15 Sekunden Timeout
  925. if self._state == ServiceState.PROCESSING and self._current_session:
  926. pwarn("ConversationEnd-Timeout — erzwinge Session-Ende")
  927. self._complete_session()
  928. self._schedule_async(_timeout_check())
  929. def _complete_session(self) -> None:
  930. """Schließt aktuelle Session ab."""
  931. session = self._current_session
  932. if session:
  933. # Callbacks
  934. for callback in self._on_recording_complete:
  935. try:
  936. callback(session)
  937. except Exception:
  938. pass
  939. self._current_session = None
  940. self._audio_buffer.clear()
  941. # Recording-Config-Override zuruecksetzen (falls temporaer geaendert)
  942. if hasattr(self, "_original_silence_timeout"):
  943. self._config.silence_timeout_seconds = self._original_silence_timeout
  944. if hasattr(self, "_original_max_duration_ms"):
  945. self._config.vad.max_duration_ms = self._original_max_duration_ms
  946. # Cooldown erneuern — verhindert Wakeword Re-Detection durch residuales
  947. # Audio im OpenWakeWord Sliding Window nach dem Resume
  948. self._last_wakeword_time = time.monotonic()
  949. # Audio-Queue leeren (verhindert Stau von alten Frames)
  950. self._drain_audio_queue()
  951. # Zurück zum Listening
  952. self.resume()
  953. async def _on_config_override(self, event_name: str, data) -> None:
  954. """
  955. Temporaere Konfigurationsaenderung fuer die naechste Aufnahme.
  956. Wird von Plugins emittiert um z.B. laengere Aufnahmen (Notizen)
  957. oder kuerzere Silence-Timeouts zu ermoeglichen.
  958. Die Werte werden in _complete_session() zurueckgesetzt.
  959. """
  960. silence = data.get("silence_timeout_seconds") if isinstance(data, dict) else getattr(data, "silence_timeout_seconds", None)
  961. max_recording = data.get("max_recording_seconds") if isinstance(data, dict) else getattr(data, "max_recording_seconds", None)
  962. if silence is not None:
  963. self._config.silence_timeout_seconds = float(silence)
  964. pdebug(f"Recording-Config Override: silence_timeout={silence}s")
  965. if max_recording is not None:
  966. self._config.vad.max_duration_ms = int(float(max_recording) * 1000)
  967. pdebug(f"Recording-Config Override: max_recording={max_recording}s")
  968. def _on_silence_detected(self) -> None:
  969. """Callback wenn Stille erkannt wurde."""
  970. pdebug("Stille erkannt")
  971. def _on_recording_timeout(self) -> None:
  972. """Callback bei Aufnahme-Timeout."""
  973. pinfo("Aufnahme-Timeout erreicht")
  974. def _on_no_speech_detected(self) -> None:
  975. """Callback wenn keine Sprache erkannt wurde."""
  976. pinfo("Keine Sprache erkannt — mögliche Fehlauslösung")
  977. def _generate_session_id(self) -> str:
  978. """Generiert eine Session-ID."""
  979. if self._session_id_generator:
  980. return self._session_id_generator()
  981. import uuid
  982. return f"ww-{uuid.uuid4().hex[:12]}"
  983. # === Public Callbacks ===
  984. def on_wakeword(self, callback: Callable[[WakewordDetection], None]) -> None:
  985. """Registriert Callback für Wakeword-Erkennung."""
  986. self._on_wakeword.append(callback)
  987. def on_recording_complete(
  988. self,
  989. callback: Callable[[RecordingSession], None],
  990. ) -> None:
  991. """Registriert Callback für abgeschlossene Aufnahme."""
  992. self._on_recording_complete.append(callback)
  993. def on_state_change(self, callback: Callable[[ServiceState], None]) -> None:
  994. """Registriert Callback für State-Änderungen."""
  995. self._on_state_change.append(callback)
  996. def set_server_connection(
  997. self,
  998. send_func: Callable[[str, dict], asyncio.Future],
  999. ) -> None:
  1000. """
  1001. Setzt Server-Kommunikationsfunktion.
  1002. Args:
  1003. send_func: Async-Funktion zum Senden an Server
  1004. """
  1005. self._send_to_server = send_func
  1006. def set_session_id_generator(self, generator: Callable[[], str]) -> None:
  1007. """Setzt Session-ID-Generator."""
  1008. self._session_id_generator = generator
  1009. def set_audio_streamer(self, streamer: Callable[[bytes], Awaitable[bool]]) -> None:
  1010. """
  1011. Setzt Audio-Streaming-Funktion (connection.send_audio).
  1012. Args:
  1013. streamer: Async-Funktion die Audio-Bytes an Server sendet
  1014. """
  1015. self._audio_streamer = streamer
  1016. def setup_for_client(self, connection: "ClientConnection", microphone: "MicrophoneCapture") -> None:
  1017. """
  1018. Konfiguriert Service für Client-Betrieb mit Netzwerk.
  1019. Args:
  1020. connection: ClientConnection zum Server
  1021. microphone: MicrophoneCapture-Instanz
  1022. """
  1023. self._microphone = microphone
  1024. self.set_audio_streamer(connection.send_audio)
  1025. # Future-basierte _send_to_server Funktion
  1026. async def send_to_server(event_type: str, data: dict) -> dict:
  1027. if event_type == "wakeword_detected":
  1028. from trixy_core.network.cmd.wakeword import WakewordDetected
  1029. cmd = WakewordDetected(
  1030. satellite_id=connection.satellite_id,
  1031. wakeword_type=data.get("wakeword_type", ""),
  1032. wakeword_model=data.get("model_name", ""),
  1033. confidence=data.get("confidence", 0.0),
  1034. audio_level=data.get("audio_level", 0.0),
  1035. audio_chunks=data.get("audio_chunks", []),
  1036. timestamp=data.get("timestamp", ""),
  1037. client_session_id=data.get("session_id", ""),
  1038. )
  1039. await connection.send_command(cmd)
  1040. # Future für Arbitration-Antwort
  1041. self._arbitration_future = asyncio.get_event_loop().create_future()
  1042. return await self._arbitration_future
  1043. elif event_type == "recording_complete":
  1044. from trixy_core.network.cmd.wakeword import RecordingComplete
  1045. cmd = RecordingComplete(
  1046. session_id=data.get("session_id", ""),
  1047. duration_seconds=data.get("duration_seconds", 0.0),
  1048. ended_by=data.get("ended_by", ""),
  1049. speech_detected=data.get("speech_detected", True),
  1050. peak_level=data.get("peak_level", 0.0),
  1051. vad_stats=data.get("vad_stats", {}),
  1052. )
  1053. await connection.send_command(cmd)
  1054. return {"success": True}
  1055. return {}
  1056. self.set_server_connection(send_to_server)
  1057. async def handle_wakeword_selected(self, data) -> None:
  1058. """
  1059. WakewordSelected vom Server → Future auflösen.
  1060. Args:
  1061. data: WakewordSelected-Daten
  1062. """
  1063. if self._arbitration_future and not self._arbitration_future.done():
  1064. session_id = getattr(data, "session_id", "") or ""
  1065. conversation_id = getattr(data, "conversation_id", "") or ""
  1066. if isinstance(data, dict):
  1067. session_id = data.get("session_id", "")
  1068. conversation_id = data.get("conversation_id", "")
  1069. self._arbitration_future.set_result({
  1070. "selected": True,
  1071. "session_id": session_id or conversation_id,
  1072. })
  1073. async def handle_wakeword_abort(self, data) -> None:
  1074. """
  1075. WakewordAbort vom Server → Future auflösen.
  1076. Args:
  1077. data: WakewordAbort-Daten
  1078. """
  1079. if self._arbitration_future and not self._arbitration_future.done():
  1080. self._arbitration_future.set_result({"selected": False})
  1081. # Cooldown-Timestamp aktualisieren damit nach Abort keine sofortige Re-Detection
  1082. self._last_wakeword_time = time.monotonic()
  1083. async def handle_conversation_end(self) -> None:
  1084. """ConversationEnd → Aufnahme beenden, zurück zu LISTENING."""
  1085. if self._state in (ServiceState.RECORDING, ServiceState.FOLLOW_UP):
  1086. self._vad.stop()
  1087. self._audio_buffer.stop_recording()
  1088. if self._state in (ServiceState.RECORDING, ServiceState.FOLLOW_UP,
  1089. ServiceState.PROCESSING, ServiceState.WAITING_RESPONSE):
  1090. pinfo("ConversationEnd empfangen — zurück zu LISTENING")
  1091. self._complete_session()
  1092. # === Server-gesteuerte Aufnahme ===
  1093. def start_server_recording(self, recording_id: str) -> None:
  1094. """Aktiviert Audio-Streaming im LISTENING-Zustand."""
  1095. self._server_recording_active = True
  1096. self._server_recording_id = recording_id
  1097. self._server_recording_start_time = time.monotonic()
  1098. pdebug(f"Server-Recording gestartet: {recording_id}")
  1099. def stop_server_recording(self) -> float:
  1100. """Beendet Audio-Streaming. Gibt Dauer zurück."""
  1101. duration = time.monotonic() - self._server_recording_start_time if self._server_recording_active else 0.0
  1102. self._server_recording_active = False
  1103. self._server_recording_id = ""
  1104. self._server_recording_start_time = 0.0
  1105. pdebug(f"Server-Recording gestoppt: {duration:.1f}s")
  1106. return duration
  1107. def set_server_recording_stopped_callback(
  1108. self, cb: Callable[[str, float], Awaitable[None]]
  1109. ) -> None:
  1110. """Setzt Callback für Wakeword-getriggertes Stop."""
  1111. self._on_server_recording_stopped = cb
  1112. def get_stats(self) -> dict:
  1113. """Gibt Statistiken zurück."""
  1114. return {
  1115. "state": self._state.value,
  1116. "detector": self._detector.stats,
  1117. "buffer": self._audio_buffer.get_stats(),
  1118. "vad": self._vad.get_stats() if self._vad.is_active else {},
  1119. "session": {
  1120. "id": self._current_session.session_id if self._current_session else None,
  1121. "follow_up_count": self._current_session.follow_up_count if self._current_session else 0,
  1122. },
  1123. }