Files
hold-slayer/core/call_manager.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

214 lines
7.5 KiB
Python

"""
Call Manager — Active call state tracking and event bus.
Central nervous system of the gateway. Tracks all active calls,
publishes events, and coordinates between SIP engine and services.
"""
import asyncio
import logging
import uuid
from collections.abc import AsyncIterator
from datetime import datetime
from typing import Optional
from core.event_bus import EventBus, EventSubscription
from models.call import ActiveCall, AudioClassification, CallMode, CallStatus, ClassificationResult
from models.events import EventType, GatewayEvent
logger = logging.getLogger(__name__)
class CallManager:
"""
Manages all active calls and their state.
The single source of truth for what's happening on the gateway.
"""
def __init__(self, event_bus: EventBus):
self.event_bus = event_bus
self._active_calls: dict[str, ActiveCall] = {}
self._call_legs: dict[str, str] = {} # SIP leg ID -> call ID mapping
self._on_call_ended = None # async callback(call: ActiveCall, final_status)
# ================================================================
# Call Lifecycle
# ================================================================
async def create_call(
self,
remote_number: str,
mode: CallMode = CallMode.DIRECT,
intent: Optional[str] = None,
call_flow_id: Optional[str] = None,
device: Optional[str] = None,
services: Optional[list[str]] = None,
) -> ActiveCall:
"""Create a new call and track it."""
call_id = f"call_{uuid.uuid4().hex[:12]}"
call = ActiveCall(
id=call_id,
remote_number=remote_number,
mode=mode,
intent=intent,
call_flow_id=call_flow_id,
device=device,
services=services or [],
)
self._active_calls[call_id] = call
await self.event_bus.publish(GatewayEvent(
type=EventType.CALL_INITIATED,
call_id=call_id,
data={"number": remote_number, "mode": mode.value, "intent": intent},
message=f"📞 Calling {remote_number} ({mode.value})",
))
return call
async def update_status(self, call_id: str, status: CallStatus) -> None:
"""Update a call's status and publish event."""
call = self._active_calls.get(call_id)
if not call:
logger.warning(f"Cannot update status: call {call_id} not found")
return
old_status = call.status
call.status = status
# Track timing milestones
if status == CallStatus.CONNECTED and not call.connected_at:
call.connected_at = datetime.now()
elif status == CallStatus.ON_HOLD:
call.hold_started_at = datetime.now()
elif status == CallStatus.HUMAN_DETECTED:
call.hold_started_at = None # Stop counting hold time
# Map status to event type
event_map = {
CallStatus.RINGING: EventType.CALL_RINGING,
CallStatus.CONNECTED: EventType.CALL_CONNECTED,
CallStatus.NAVIGATING_IVR: EventType.IVR_STEP,
CallStatus.ON_HOLD: EventType.HOLD_DETECTED,
CallStatus.HUMAN_DETECTED: EventType.HUMAN_DETECTED,
CallStatus.TRANSFERRING: EventType.TRANSFER_STARTED,
CallStatus.BRIDGED: EventType.TRANSFER_COMPLETE,
CallStatus.COMPLETED: EventType.CALL_ENDED,
CallStatus.FAILED: EventType.CALL_FAILED,
}
event_type = event_map.get(status, EventType.CALL_CONNECTED)
await self.event_bus.publish(GatewayEvent(
type=event_type,
call_id=call_id,
data={
"old_status": old_status.value,
"new_status": status.value,
"duration": call.duration,
"hold_time": call.hold_time,
},
message=f"Call {call_id}: {old_status.value}{status.value}",
))
async def add_classification(
self, call_id: str, result: ClassificationResult
) -> None:
"""Add an audio classification result to a call."""
call = self._active_calls.get(call_id)
if not call:
return
call.current_classification = result.audio_type
call.classification_history.append(result)
await self.event_bus.publish(GatewayEvent(
type=EventType.AUDIO_CLASSIFIED,
call_id=call_id,
data={
"audio_type": result.audio_type.value,
"confidence": result.confidence,
},
message=f"🎵 Audio: {result.audio_type.value} ({result.confidence:.0%})",
))
async def add_transcript(self, call_id: str, text: str) -> None:
"""Add a transcript chunk to a call."""
call = self._active_calls.get(call_id)
if not call:
return
call.transcript_chunks.append(text)
await self.event_bus.publish(GatewayEvent(
type=EventType.TRANSCRIPT_CHUNK,
call_id=call_id,
data={"text": text},
message=f"📝 '{text[:80]}...' " if len(text) > 80 else f"📝 '{text}'",
))
async def end_call(self, call_id: str, status: CallStatus = CallStatus.COMPLETED) -> Optional[ActiveCall]:
"""End a call and remove from active tracking."""
call = self._active_calls.pop(call_id, None)
if call:
call.status = status
await self.event_bus.publish(GatewayEvent(
type=EventType.CALL_ENDED,
call_id=call_id,
data={
"duration": call.duration,
"hold_time": call.hold_time,
"final_status": status.value,
},
message=f"📵 Call ended: {call.remote_number} ({call.duration}s, hold: {call.hold_time}s)",
))
if self._on_call_ended is not None:
try:
await self._on_call_ended(call, status)
except Exception as e:
logger.warning(f"on_call_ended hook failed for {call_id}: {e}")
return call
# ================================================================
# Leg Mapping
# ================================================================
def map_leg(self, sip_leg_id: str, call_id: str) -> None:
"""Map a SIP leg ID to a call ID."""
self._call_legs[sip_leg_id] = call_id
def unmap_leg(self, sip_leg_id: str) -> None:
"""Remove a SIP leg mapping (leg terminated)."""
self._call_legs.pop(sip_leg_id, None)
def legs_for_call(self, call_id: str) -> list[str]:
"""All SIP leg IDs currently mapped to a call."""
return [leg for leg, cid in self._call_legs.items() if cid == call_id]
def get_call_for_leg(self, sip_leg_id: str) -> Optional[ActiveCall]:
"""Look up which call a SIP leg belongs to."""
call_id = self._call_legs.get(sip_leg_id)
if call_id:
return self._active_calls.get(call_id)
return None
# ================================================================
# Queries
# ================================================================
def get_call(self, call_id: str) -> Optional[ActiveCall]:
"""Get an active call by ID."""
return self._active_calls.get(call_id)
@property
def active_calls(self) -> dict[str, ActiveCall]:
"""All active calls."""
return dict(self._active_calls)
@property
def active_call_count(self) -> int:
return len(self._active_calls)