media_session.py 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  1. from __future__ import annotations
  2. import asyncio
  3. import subprocess
  4. from pathlib import Path
  5. import logging
  6. from typing import Any, Awaitable, Callable
  7. from .media import ThreeCXMediaClient
  8. from .whisper import WhisperWorker
  9. logger = logging.getLogger(__name__)
  10. TranscriptCallback = Callable[
  11. [dict[str, Any]],
  12. Awaitable[None],
  13. ]
  14. class MediaSession:
  15. """
  16. Kontinuierliche MediaSession.
  17. Ein 3CX-Stream bleibt geöffnet.
  18. Das PCM wird in Segmente zerlegt und jedes
  19. Segment an Whisper übergeben.
  20. """
  21. def __init__(
  22. self,
  23. settings,
  24. media_client: ThreeCXMediaClient,
  25. whisper: WhisperWorker,
  26. on_transcript: TranscriptCallback | None = None,
  27. ):
  28. self.settings = settings
  29. self.media_client = media_client
  30. self.whisper = whisper
  31. self.on_transcript = on_transcript
  32. self.segment_seconds = 5
  33. async def process_participant(
  34. self,
  35. participant: dict[str, Any],
  36. max_bytes: int | None = None,
  37. ) -> dict[str, Any]:
  38. participant_id = participant["id"]
  39. call_id = participant.get("callid")
  40. leg_id = participant.get("legid")
  41. recording_dir = Path(
  42. self.settings.media_recording_dir
  43. )
  44. recording_dir.mkdir(
  45. parents=True,
  46. exist_ok=True,
  47. )
  48. bytes_per_second = (
  49. self.settings.media_sample_rate
  50. * self.settings.media_channels
  51. * 2
  52. )
  53. segment_bytes = int(
  54. bytes_per_second
  55. * self.segment_seconds
  56. )
  57. result = {
  58. "callid": call_id,
  59. "legid": leg_id,
  60. "participant_id": participant_id,
  61. "audio_bytes": 0,
  62. "segments": [],
  63. }
  64. # Der 3CX-Client schreibt hier zunächst weiterhin
  65. # einen einzelnen PCM-Stream.
  66. # Für den kontinuierlichen Betrieb verwenden wir
  67. # einen temporären Stream-Puffer.
  68. pcm_path = (
  69. recording_dir
  70. / f"media-{call_id}-{leg_id}-{participant_id}.pcm"
  71. )
  72. token = await self.media_client.token()
  73. url = (
  74. f"{self.settings.threecx_base_url}"
  75. f"/callcontrol/"
  76. f"{self.settings.threecx_media_routepoint_dn}"
  77. f"/participants/{participant_id}/stream"
  78. )
  79. received = 0
  80. buffer = bytearray()
  81. segment_no = 0
  82. import httpx
  83. async with httpx.AsyncClient(
  84. verify=self.settings.threecx_verify_tls,
  85. timeout=None,
  86. ) as client:
  87. async with client.stream(
  88. "GET",
  89. url,
  90. headers={
  91. "Authorization": f"Bearer {token}",
  92. "Accept": "application/octet-stream",
  93. },
  94. ) as response:
  95. response.raise_for_status()
  96. try:
  97. async for chunk in response.aiter_bytes(8192):
  98. buffer.extend(chunk)
  99. received += len(chunk)
  100. if max_bytes is not None:
  101. remaining = max_bytes - received
  102. if remaining <= 0:
  103. break
  104. while len(buffer) >= segment_bytes:
  105. segment = bytes(
  106. buffer[:segment_bytes]
  107. )
  108. del buffer[:segment_bytes]
  109. segment_no += 1
  110. transcript = await self._process_segment(
  111. recording_dir,
  112. call_id,
  113. leg_id,
  114. participant_id,
  115. segment_no,
  116. segment,
  117. )
  118. result["segments"].append(
  119. transcript
  120. )
  121. if self.on_transcript:
  122. await self.on_transcript(
  123. {
  124. **transcript,
  125. "callid": call_id,
  126. "legid": leg_id,
  127. "participant_id": participant_id,
  128. }
  129. )
  130. if (
  131. max_bytes is not None
  132. and received >= max_bytes
  133. ):
  134. break
  135. except httpx.RemoteProtocolError:
  136. # 3CX kann den HTTP-Stream beim Gesprächsende
  137. # ohne vollständige HTTP-Antwort schließen.
  138. # Bereits empfangenes Audio bleibt erhalten.
  139. logger.info(
  140. "3CX Media Stream beendet: "
  141. "call=%s participant=%s bytes=%s",
  142. call_id,
  143. participant_id,
  144. received,
  145. )
  146. # Restsegment verarbeiten, falls ausreichend Audio
  147. # vorhanden ist.
  148. if buffer:
  149. segment_no += 1
  150. transcript = await self._process_segment(
  151. recording_dir,
  152. call_id,
  153. leg_id,
  154. participant_id,
  155. segment_no,
  156. bytes(buffer),
  157. )
  158. result["segments"].append(
  159. transcript
  160. )
  161. result["audio_bytes"] = received
  162. return result
  163. async def _process_segment(
  164. self,
  165. recording_dir: Path,
  166. call_id: Any,
  167. leg_id: Any,
  168. participant_id: Any,
  169. segment_no: int,
  170. pcm_data: bytes,
  171. ) -> dict[str, Any]:
  172. pcm_path = (
  173. recording_dir
  174. / (
  175. f"segment-{call_id}-"
  176. f"{leg_id}-{participant_id}-"
  177. f"{segment_no}.pcm"
  178. )
  179. )
  180. wav_path = pcm_path.with_suffix(".wav")
  181. await asyncio.to_thread(
  182. pcm_path.write_bytes,
  183. pcm_data,
  184. )
  185. await self._pcm_to_wav(
  186. pcm_path,
  187. wav_path,
  188. )
  189. transcription = await asyncio.to_thread(
  190. self.whisper.transcribe,
  191. wav_path,
  192. )
  193. return {
  194. "segment": segment_no,
  195. "audio_bytes": len(pcm_data),
  196. "audio_file": str(wav_path),
  197. "text": transcription["text"],
  198. "language": transcription["language"],
  199. "language_probability":
  200. transcription["language_probability"],
  201. }
  202. async def _pcm_to_wav(
  203. self,
  204. pcm_path: Path,
  205. wav_path: Path,
  206. ) -> None:
  207. process = await asyncio.create_subprocess_exec(
  208. "ffmpeg",
  209. "-y",
  210. "-f",
  211. "s16le",
  212. "-ar",
  213. str(self.settings.media_sample_rate),
  214. "-ac",
  215. str(self.settings.media_channels),
  216. "-i",
  217. str(pcm_path),
  218. "-ar",
  219. str(self.settings.media_output_sample_rate),
  220. "-ac",
  221. "1",
  222. "-c:a",
  223. "pcm_s16le",
  224. str(wav_path),
  225. stdout=asyncio.subprocess.DEVNULL,
  226. stderr=asyncio.subprocess.PIPE,
  227. )
  228. _, stderr = await process.communicate()
  229. if process.returncode != 0:
  230. raise RuntimeError(
  231. "ffmpeg Fehler: "
  232. + stderr.decode(errors="replace")
  233. )