| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289 |
- from __future__ import annotations
- import asyncio
- import subprocess
- from pathlib import Path
- import logging
- from typing import Any, Awaitable, Callable
- from .media import ThreeCXMediaClient
- from .whisper import WhisperWorker
- logger = logging.getLogger(__name__)
- TranscriptCallback = Callable[
- [dict[str, Any]],
- Awaitable[None],
- ]
- class MediaSession:
- """
- Kontinuierliche MediaSession.
- Ein 3CX-Stream bleibt geöffnet.
- Das PCM wird in Segmente zerlegt und jedes
- Segment an Whisper übergeben.
- """
- def __init__(
- self,
- settings,
- media_client: ThreeCXMediaClient,
- whisper: WhisperWorker,
- on_transcript: TranscriptCallback | None = None,
- ):
- self.settings = settings
- self.media_client = media_client
- self.whisper = whisper
- self.on_transcript = on_transcript
- self.segment_seconds = 5
- async def process_participant(
- self,
- participant: dict[str, Any],
- max_bytes: int | None = None,
- ) -> dict[str, Any]:
- participant_id = participant["id"]
- call_id = participant.get("callid")
- leg_id = participant.get("legid")
- recording_dir = Path(
- self.settings.media_recording_dir
- )
- recording_dir.mkdir(
- parents=True,
- exist_ok=True,
- )
- bytes_per_second = (
- self.settings.media_sample_rate
- * self.settings.media_channels
- * 2
- )
- segment_bytes = int(
- bytes_per_second
- * self.segment_seconds
- )
- result = {
- "callid": call_id,
- "legid": leg_id,
- "participant_id": participant_id,
- "audio_bytes": 0,
- "segments": [],
- }
- # Der 3CX-Client schreibt hier zunächst weiterhin
- # einen einzelnen PCM-Stream.
- # Für den kontinuierlichen Betrieb verwenden wir
- # einen temporären Stream-Puffer.
- pcm_path = (
- recording_dir
- / f"media-{call_id}-{leg_id}-{participant_id}.pcm"
- )
- token = await self.media_client.token()
- url = (
- f"{self.settings.threecx_base_url}"
- f"/callcontrol/"
- f"{self.settings.threecx_media_routepoint_dn}"
- f"/participants/{participant_id}/stream"
- )
- received = 0
- buffer = bytearray()
- segment_no = 0
- import httpx
- async with httpx.AsyncClient(
- verify=self.settings.threecx_verify_tls,
- timeout=None,
- ) as client:
- async with client.stream(
- "GET",
- url,
- headers={
- "Authorization": f"Bearer {token}",
- "Accept": "application/octet-stream",
- },
- ) as response:
- response.raise_for_status()
- try:
- async for chunk in response.aiter_bytes(8192):
- buffer.extend(chunk)
- received += len(chunk)
- if max_bytes is not None:
- remaining = max_bytes - received
- if remaining <= 0:
- break
- while len(buffer) >= segment_bytes:
- segment = bytes(
- buffer[:segment_bytes]
- )
- del buffer[:segment_bytes]
- segment_no += 1
- transcript = await self._process_segment(
- recording_dir,
- call_id,
- leg_id,
- participant_id,
- segment_no,
- segment,
- )
- result["segments"].append(
- transcript
- )
- if self.on_transcript:
- await self.on_transcript(
- {
- **transcript,
- "callid": call_id,
- "legid": leg_id,
- "participant_id": participant_id,
- }
- )
- if (
- max_bytes is not None
- and received >= max_bytes
- ):
- break
- except httpx.RemoteProtocolError:
- # 3CX kann den HTTP-Stream beim Gesprächsende
- # ohne vollständige HTTP-Antwort schließen.
- # Bereits empfangenes Audio bleibt erhalten.
- logger.info(
- "3CX Media Stream beendet: "
- "call=%s participant=%s bytes=%s",
- call_id,
- participant_id,
- received,
- )
- # Restsegment verarbeiten, falls ausreichend Audio
- # vorhanden ist.
- if buffer:
- segment_no += 1
- transcript = await self._process_segment(
- recording_dir,
- call_id,
- leg_id,
- participant_id,
- segment_no,
- bytes(buffer),
- )
- result["segments"].append(
- transcript
- )
- result["audio_bytes"] = received
- return result
- async def _process_segment(
- self,
- recording_dir: Path,
- call_id: Any,
- leg_id: Any,
- participant_id: Any,
- segment_no: int,
- pcm_data: bytes,
- ) -> dict[str, Any]:
- pcm_path = (
- recording_dir
- / (
- f"segment-{call_id}-"
- f"{leg_id}-{participant_id}-"
- f"{segment_no}.pcm"
- )
- )
- wav_path = pcm_path.with_suffix(".wav")
- await asyncio.to_thread(
- pcm_path.write_bytes,
- pcm_data,
- )
- await self._pcm_to_wav(
- pcm_path,
- wav_path,
- )
- transcription = await asyncio.to_thread(
- self.whisper.transcribe,
- wav_path,
- )
- return {
- "segment": segment_no,
- "audio_bytes": len(pcm_data),
- "audio_file": str(wav_path),
- "text": transcription["text"],
- "language": transcription["language"],
- "language_probability":
- transcription["language_probability"],
- }
- async def _pcm_to_wav(
- self,
- pcm_path: Path,
- wav_path: Path,
- ) -> None:
- process = await asyncio.create_subprocess_exec(
- "ffmpeg",
- "-y",
- "-f",
- "s16le",
- "-ar",
- str(self.settings.media_sample_rate),
- "-ac",
- str(self.settings.media_channels),
- "-i",
- str(pcm_path),
- "-ar",
- str(self.settings.media_output_sample_rate),
- "-ac",
- "1",
- "-c:a",
- "pcm_s16le",
- str(wav_path),
- stdout=asyncio.subprocess.DEVNULL,
- stderr=asyncio.subprocess.PIPE,
- )
- _, stderr = await process.communicate()
- if process.returncode != 0:
- raise RuntimeError(
- "ffmpeg Fehler: "
- + stderr.decode(errors="replace")
- )
|