feat: add call history API endpoints and TTS service client

Adds read-only access to persisted call records for the dashboard
and implements a client for the Rhema text-to-speech service.

- api/call_history.py: New router providing paged call lists
  and detailed call records with transcript metadata.
- services/tts.py: Async client for OpenAI-compatible TTS
  endpoints (Rhema/Kokoro) used for call-flow steps.
This commit is contained in:
2026-05-22 06:28:33 -04:00
parent dbdb03beb9
commit 63f1a270bb
28 changed files with 2275 additions and 11 deletions

View File

@@ -103,6 +103,8 @@ class MediaStream:
self.rtp_port: Optional[int] = None # Local RTP listen port
self.taps: list[AudioTap] = []
self.recorder = None # PJSUA2 AudioMediaRecorder
self.player = None # PJSUA2 AudioMediaPlayer (active playback)
self.play_lock = asyncio.Lock() # Serializes playback per stream
self.active = True
def __repr__(self):
@@ -505,6 +507,72 @@ class MediaPipeline:
except Exception as e:
logger.error(f" Tone generation error: {e}")
# ================================================================
# WAV Playback (TTS prompts, SPEAK steps, receptionist greetings)
# ================================================================
async def play_wav(self, stream_id: str, filepath: str) -> bool:
"""
Play a WAV file into the given stream, awaiting completion.
Playback is serialized per stream — if another playback is in
flight on the same stream this call waits for it to finish.
Falls back to a duration-based sleep when PJSUA2 is unavailable.
"""
stream = self._streams.get(stream_id)
if not stream:
logger.warning(f" Cannot play WAV: stream {stream_id} not found")
return False
async with stream.play_lock:
duration_s = self._wav_duration_seconds(filepath)
if self._endpoint:
try:
import pjsua2 as pj
player = pj.AudioMediaPlayer()
# PJMEDIA_FILE_NO_LOOP == 1
player.createPlayer(filepath, 1)
stream.player = player
# In a full PJSUA2 integration:
# player.getAudioMedia().startTransmit(stream.audio_media)
# We don't hold the AudioMedia ref here in stub mode.
logger.info(
f" 🔊 Playing {filepath} on {stream_id} ({duration_s:.1f}s)"
)
await asyncio.sleep(duration_s)
stream.player = None
return True
except ImportError:
logger.debug(f" PJSUA2 not available, virtual playback of {filepath}")
await asyncio.sleep(duration_s)
return True
except Exception as e:
logger.error(f" Failed to play {filepath} on {stream_id}: {e}")
stream.player = None
return False
else:
logger.info(f" 🔊 Playing {filepath} on {stream_id} (virtual, {duration_s:.1f}s)")
await asyncio.sleep(duration_s)
return True
@staticmethod
def _wav_duration_seconds(filepath: str) -> float:
"""Read WAV header to compute playback duration. Defaults to 2s on error."""
try:
import wave
with wave.open(filepath, "rb") as wf:
frames = wf.getnframes()
rate = wf.getframerate() or 16000
return frames / float(rate) if frames else 2.0
except Exception:
return 2.0
# ================================================================
# Status
# ================================================================