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:
@@ -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
|
||||
# ================================================================
|
||||
|
||||
Reference in New Issue
Block a user