media_manager.py 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  1. from __future__ import annotations
  2. import asyncio
  3. import logging
  4. from typing import Any
  5. from .config import Settings
  6. from .media import ThreeCXMediaClient
  7. from .media_session import MediaSession
  8. from .whisper import WhisperWorker
  9. logger = logging.getLogger(__name__)
  10. class MediaManager:
  11. def __init__(self, settings: Settings):
  12. self.settings = settings
  13. self.media = ThreeCXMediaClient(settings)
  14. self.whisper = WhisperWorker(settings)
  15. self.session = MediaSession(
  16. settings,
  17. self.media,
  18. self.whisper,
  19. on_transcript=self._on_transcript,
  20. )
  21. self._running = False
  22. self._poll_task: asyncio.Task | None = None
  23. self._active: dict[str, asyncio.Task] = {}
  24. self._completed: set[str] = set()
  25. # 3CX hat aktuell 8 SC.
  26. # Maximal 8 MediaSessions dürfen gleichzeitig laufen.
  27. self._session_semaphore = asyncio.Semaphore(8)
  28. async def start(self) -> None:
  29. if self._running:
  30. return
  31. self._running = True
  32. logger.info(
  33. "MediaManager startet für RoutePoint %s",
  34. self.settings.threecx_media_routepoint_dn,
  35. )
  36. logger.info("Lade Whisper-Modell ...")
  37. await asyncio.to_thread(
  38. self.whisper.start
  39. )
  40. logger.info("Whisper-Modell geladen")
  41. self._poll_task = asyncio.create_task(
  42. self._poll_loop()
  43. )
  44. async def stop(self) -> None:
  45. self._running = False
  46. if self._poll_task:
  47. self._poll_task.cancel()
  48. try:
  49. await self._poll_task
  50. except asyncio.CancelledError:
  51. pass
  52. self._poll_task = None
  53. tasks = list(self._active.values())
  54. if tasks:
  55. await asyncio.gather(
  56. *tasks,
  57. return_exceptions=True,
  58. )
  59. self._active.clear()
  60. self._completed.clear()
  61. logger.info("MediaManager gestoppt")
  62. async def _poll_loop(self) -> None:
  63. while self._running:
  64. try:
  65. await self._poll_once()
  66. except asyncio.CancelledError:
  67. raise
  68. except Exception:
  69. logger.exception(
  70. "Fehler im MediaManager-Poll"
  71. )
  72. await asyncio.sleep(1)
  73. async def _poll_once(self) -> None:
  74. participants = await self.media.participants()
  75. current_keys: set[str] = set()
  76. for participant in participants:
  77. key = self._participant_key(participant)
  78. current_keys.add(key)
  79. if not self._is_processable(participant):
  80. continue
  81. if key in self._active:
  82. continue
  83. if key in self._completed:
  84. continue
  85. logger.info(
  86. "Neuer Media-Participant: "
  87. "call=%s leg=%s participant=%s",
  88. participant.get("callid"),
  89. participant.get("legid"),
  90. participant.get("id"),
  91. )
  92. task = asyncio.create_task(
  93. self._run_session(
  94. participant,
  95. key,
  96. )
  97. )
  98. self._active[key] = task
  99. task.add_done_callback(
  100. lambda t, k=key:
  101. self._session_done(k, t)
  102. )
  103. self._completed.intersection_update(
  104. current_keys
  105. )
  106. @staticmethod
  107. def _is_processable(
  108. participant: dict[str, Any],
  109. ) -> bool:
  110. status = str(
  111. participant.get("status", "")
  112. ).lower()
  113. return (
  114. status == "connected"
  115. and participant.get("id") is not None
  116. and participant.get("callid") is not None
  117. )
  118. @staticmethod
  119. def _participant_key(
  120. participant: dict[str, Any],
  121. ) -> str:
  122. return (
  123. f"{participant.get('callid')}:"
  124. f"{participant.get('legid')}:"
  125. f"{participant.get('id')}"
  126. )
  127. async def _run_session(
  128. self,
  129. participant: dict[str, Any],
  130. key: str,
  131. ) -> None:
  132. try:
  133. async with self._session_semaphore:
  134. result = await self.session.process_participant(
  135. participant,
  136. )
  137. logger.info(
  138. "MediaSession abgeschlossen: "
  139. "call=%s participant=%s "
  140. "audio=%s bytes segments=%s",
  141. result["callid"],
  142. result["participant_id"],
  143. result["audio_bytes"],
  144. len(result["segments"]),
  145. )
  146. self._completed.add(key)
  147. except Exception:
  148. logger.exception(
  149. "MediaSession fehlgeschlagen: %s",
  150. key,
  151. )
  152. async def _on_transcript(
  153. self,
  154. transcript: dict[str, Any],
  155. ) -> None:
  156. logger.info(
  157. "TRANSCRIPT "
  158. "call=%s leg=%s participant=%s "
  159. "segment=%s text=%r",
  160. transcript.get("callid"),
  161. transcript.get("legid"),
  162. transcript.get("participant_id"),
  163. transcript.get("segment"),
  164. transcript.get("text"),
  165. )
  166. def _session_done(
  167. self,
  168. key: str,
  169. task: asyncio.Task,
  170. ) -> None:
  171. self._active.pop(key, None)
  172. try:
  173. task.result()
  174. except asyncio.CancelledError:
  175. pass
  176. except Exception:
  177. logger.exception(
  178. "MediaSession Task beendet mit Fehler: %s",
  179. key,
  180. )