Files
hold-slayer/services/recording.py
Robert Helewka 5880b59872 fix: enforce thread ownership at the Sippy/PJSUA2 boundary
Three thread domains were mutating shared dicts with no locks: Sippy's
ED thread wrote _legs/_registered_devices directly from SIP handlers,
the asyncio loop wrote them from make_call/hangup, and
run_in_executor(None, ...) had default-pool threads driving sippy UA
objects. AudioTap.feed() pushed into an asyncio.Queue (not
thread-safe) from the PJSUA2 thread.

New ownership rule, enforced structurally:
- The asyncio loop owns all app-visible state; the only mutator is the
  new _on_engine_event funnel. Sippy handlers extract plain strings on
  the ED thread and post via run_coroutine_threadsafe.
- The ED thread owns sippy objects plus _ed_ua_to_leg/_ed_leg_to_ua;
  loop-side commands (INVITE/BYE/DTMF/trunk register) hop over via
  ED2.callFromThread. UA references no longer live on SipCallLeg.
- AudioTap captures its loop and feed() hops via call_soon_threadsafe.
- Fix ED import: installed sippy 2.x exposes ED2, not ED — the old
  import could never start the event loop.

Also:
- Wire the never-connected on_leg_state_change callback: outbound
  ringing/connected/terminated now reaches CallManager; a call ends
  when its last leg terminates (transfers keep it alive). Adds
  CallManager.unmap_leg/legs_for_call.
- AudioClassifier.classify(): async entry that runs the FFT work in
  asyncio.to_thread and updates history on the loop — all four
  hold_slayer call sites now route through it, fixing both the
  loop-blocking and the 2-of-4 history gap. DTMF Goertzel loop
  replaced by the equivalent vectorized DFT-bin power.
- Task hygiene: gateway.spawn() tracks hold-slayer/receptionist tasks
  and stop() cancels them; recording safety-timeout task is retained
  and cancelled on stop_recording; engine tracks incoming-call
  dispatch tasks.

10 new tests: funnel events from a foreign thread, auto-answer
fallback, AudioTap cross-thread feed, classifier history, leg-state →
call status (including no stomping of ON_HOLD), stop() cancellation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 19:53:36 -04:00

266 lines
9.2 KiB
Python

"""
Recording Service — Call recording management.
Records calls to WAV files via the PJSUA2 media pipeline,
manages storage, and provides playback/download access.
"""
import asyncio
import logging
import os
from datetime import datetime
from pathlib import Path
from typing import Optional
from config import get_settings
logger = logging.getLogger(__name__)
class RecordingService:
"""
Manages call recordings.
Features:
- Start/stop recording for any active call leg
- Dual-channel recording (separate caller/agent streams)
- Mixed recording (both parties in one file)
- WAV storage with organized directory structure
- Recording metadata tracking
"""
def __init__(
self,
storage_dir: str = "recordings",
max_recording_seconds: int = 7200, # 2 hours
sample_rate: int = 16000,
):
self._storage_dir = Path(storage_dir)
self._max_recording_seconds = max_recording_seconds
self._sample_rate = sample_rate
self._active_recordings: dict[str, RecordingSession] = {}
self._timeout_tasks: dict[str, asyncio.Task] = {}
self._metadata: list[dict] = []
async def start(self) -> None:
"""Initialize the recording service."""
self._storage_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"🎙️ Recording service ready (storage: {self._storage_dir})")
# ================================================================
# Recording Lifecycle
# ================================================================
async def start_recording(
self,
call_id: str,
media_pipeline=None,
leg_ids: Optional[list[str]] = None,
dual_channel: bool = False,
) -> "RecordingSession":
"""
Start recording a call.
Args:
call_id: The call to record.
media_pipeline: MediaPipeline instance for PJSUA2 recording.
leg_ids: Specific SIP leg IDs to record. If None, records all legs.
dual_channel: If True, record each party to a separate channel.
Returns:
RecordingSession with file paths and metadata.
"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
date_dir = datetime.now().strftime("%Y-%m-%d")
recording_dir = self._storage_dir / date_dir
recording_dir.mkdir(parents=True, exist_ok=True)
if dual_channel:
filepath_caller = str(recording_dir / f"{call_id}_{timestamp}_caller.wav")
filepath_agent = str(recording_dir / f"{call_id}_{timestamp}_agent.wav")
filepath_mixed = str(recording_dir / f"{call_id}_{timestamp}_mixed.wav")
else:
filepath_caller = None
filepath_agent = None
filepath_mixed = str(recording_dir / f"{call_id}_{timestamp}.wav")
session = RecordingSession(
call_id=call_id,
filepath_mixed=filepath_mixed,
filepath_caller=filepath_caller,
filepath_agent=filepath_agent,
started_at=datetime.now(),
sample_rate=self._sample_rate,
)
# Start PJSUA2 recording if media pipeline is available
if media_pipeline and leg_ids:
for leg_id in leg_ids:
if filepath_mixed:
media_pipeline.start_recording(leg_id, filepath_mixed)
self._active_recordings[call_id] = session
logger.info(f"🔴 Recording started: {call_id}{filepath_mixed}")
# Safety timeout — tracked so it can be cancelled and isn't GC'd
self._timeout_tasks[call_id] = asyncio.create_task(
self._recording_timeout(call_id),
name=f"rec_timeout_{call_id}",
)
return session
async def stop_recording(
self,
call_id: str,
media_pipeline=None,
) -> Optional["RecordingSession"]:
"""Stop recording a call and finalize the WAV file."""
timeout_task = self._timeout_tasks.pop(call_id, None)
if (
timeout_task is not None
and timeout_task is not asyncio.current_task()
and not timeout_task.done()
):
timeout_task.cancel()
session = self._active_recordings.pop(call_id, None)
if not session:
logger.warning(f" No active recording for {call_id}")
return None
session.stopped_at = datetime.now()
session.duration_seconds = int(
(session.stopped_at - session.started_at).total_seconds()
)
# Stop PJSUA2 recording
if media_pipeline:
# The pipeline handles flushing and closing the WAV file
for leg_id in (session._leg_ids or []):
media_pipeline.stop_recording(leg_id)
# Calculate file size
if session.filepath_mixed and os.path.exists(session.filepath_mixed):
session.file_size_bytes = os.path.getsize(session.filepath_mixed)
# Store metadata
self._metadata.append(session.to_dict())
# Persist a recording row so the dashboard can find it later
await self._persist_recording(session)
logger.info(
f"⏹ Recording stopped: {call_id} "
f"({session.duration_seconds}s, "
f"{session.file_size_bytes or 0} bytes)"
)
return session
@staticmethod
async def _persist_recording(session: "RecordingSession") -> None:
"""Write a recordings row for this session. Failures are non-fatal."""
try:
import uuid as _uuid
from db.database import RecordingRecord, get_session_factory
async with get_session_factory()() as db:
db.add(RecordingRecord(
id=f"rec_{_uuid.uuid4().hex[:10]}",
call_id=session.call_id,
path=session.filepath_mixed or "",
format="wav",
duration_s=float(session.duration_seconds or 0),
size_bytes=int(session.file_size_bytes or 0),
channels=1,
started_at=session.started_at,
ended_at=session.stopped_at,
))
await db.commit()
except Exception as e:
logger.warning(f"Recording persistence failed: {e}")
async def _recording_timeout(self, call_id: str) -> None:
"""Auto-stop recording after max duration."""
await asyncio.sleep(self._max_recording_seconds)
if call_id in self._active_recordings:
logger.warning(f" Recording timeout for {call_id}, auto-stopping")
await self.stop_recording(call_id)
# ================================================================
# Queries
# ================================================================
def get_recording(self, call_id: str) -> Optional[dict]:
"""Get recording metadata for a call."""
for meta in reversed(self._metadata):
if meta["call_id"] == call_id:
return meta
return None
def list_recordings(
self,
limit: int = 50,
offset: int = 0,
) -> list[dict]:
"""List recording metadata, newest first."""
sorted_meta = sorted(
self._metadata,
key=lambda m: m.get("started_at", ""),
reverse=True,
)
return sorted_meta[offset : offset + limit]
@property
def active_recording_count(self) -> int:
return len(self._active_recordings)
@property
def total_recordings(self) -> int:
return len(self._metadata)
def storage_usage_bytes(self) -> int:
"""Calculate total storage used by recordings."""
total = 0
for root, _dirs, files in os.walk(self._storage_dir):
for f in files:
total += os.path.getsize(os.path.join(root, f))
return total
class RecordingSession:
"""Tracks a single active recording session."""
def __init__(
self,
call_id: str,
filepath_mixed: Optional[str] = None,
filepath_caller: Optional[str] = None,
filepath_agent: Optional[str] = None,
started_at: Optional[datetime] = None,
sample_rate: int = 16000,
):
self.call_id = call_id
self.filepath_mixed = filepath_mixed
self.filepath_caller = filepath_caller
self.filepath_agent = filepath_agent
self.started_at = started_at or datetime.now()
self.stopped_at: Optional[datetime] = None
self.duration_seconds: Optional[int] = None
self.file_size_bytes: Optional[int] = None
self.sample_rate = sample_rate
self._leg_ids: list[str] = []
def to_dict(self) -> dict:
return {
"call_id": self.call_id,
"filepath_mixed": self.filepath_mixed,
"filepath_caller": self.filepath_caller,
"filepath_agent": self.filepath_agent,
"started_at": self.started_at.isoformat() if self.started_at else None,
"stopped_at": self.stopped_at.isoformat() if self.stopped_at else None,
"duration_seconds": self.duration_seconds,
"file_size_bytes": self.file_size_bytes,
"sample_rate": self.sample_rate,
}