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>
This commit is contained in:
2026-07-09 19:53:36 -04:00
parent 94fb6cd79d
commit 5880b59872
8 changed files with 548 additions and 220 deletions

View File

@@ -12,6 +12,7 @@ Uses spectral analysis (librosa/numpy) to classify audio without needing
a trained ML model — just signal processing and heuristics.
"""
import asyncio
import logging
import time
from typing import Optional
@@ -47,9 +48,23 @@ class AudioClassifier:
self._window_samples = int(settings.window_seconds * SAMPLE_RATE)
self._classification_history: list[AudioClassification] = []
async def classify(self, audio_data: bytes) -> ClassificationResult:
"""
Classify a chunk off the event loop and record it in the history.
The FFT/autocorrelation work is CPU-bound, so the pure
`classify_chunk` runs in a worker thread; the history update
happens back on the loop, keeping it single-threaded. This is
the call sites' entry point — routing every classification
through here is what keeps the history complete.
"""
result = await asyncio.to_thread(self.classify_chunk, audio_data)
self.update_history(result.audio_type)
return result
def classify_chunk(self, audio_data: bytes) -> ClassificationResult:
"""
Classify a chunk of audio data.
Classify a chunk of audio data (pure, synchronous).
Args:
audio_data: Raw PCM audio (16-bit signed, 16kHz, mono)
@@ -285,17 +300,15 @@ class AudioClassifier:
(941, 1209): "*", (941, 1336): "0", (941, 1477): "#", (941, 1633): "D",
}
# Compute power at each DTMF frequency
# Power at each DTMF frequency via the DFT bin (numerically equal
# to the Goertzel result s1² + s2² coeff·s1·s2, but vectorized —
# the per-sample Python loop blocked for ~50ms per chunk)
n = np.arange(len(samples))
def goertzel_power(freq: int) -> float:
k = int(0.5 + len(samples) * freq / SAMPLE_RATE)
w = 2 * np.pi * k / len(samples)
coeff = 2 * np.cos(w)
s0, s1, s2 = 0.0, 0.0, 0.0
for sample in samples:
s0 = sample + coeff * s1 - s2
s2 = s1
s1 = s0
return float(s1 * s1 + s2 * s2 - coeff * s1 * s2)
bin_value = np.dot(samples, np.exp(-2j * np.pi * k * n / len(samples)))
return float(np.abs(bin_value) ** 2)
# Find strongest low and high frequencies
low_powers = [(f, goertzel_power(f)) for f in dtmf_freqs_low]

View File

@@ -323,8 +323,7 @@ class HoldSlayerService:
continue
# Classify the audio
classification = self.classifier.classify_chunk(audio_chunk)
self.classifier.update_history(classification.audio_type)
classification = await self.classifier.classify(audio_chunk)
await self.call_manager.add_classification(call.id, classification)
# Transcribe if it sounds like speech
@@ -447,8 +446,7 @@ class HoldSlayerService:
continue
# Classify
result = self.classifier.classify_chunk(audio_chunk)
self.classifier.update_history(result.audio_type)
result = await self.classifier.classify(audio_chunk)
await self.call_manager.add_classification(call.id, result)
# Check for human
@@ -508,7 +506,7 @@ class HoldSlayerService:
continue
# Classify first
result = self.classifier.classify_chunk(audio_chunk)
result = await self.classifier.classify(audio_chunk)
if result.audio_type not in (
AudioClassification.IVR_PROMPT,
AudioClassification.LIVE_HUMAN,
@@ -560,7 +558,7 @@ class HoldSlayerService:
if not audio_chunk:
break
result = self.classifier.classify_chunk(audio_chunk)
result = await self.classifier.classify(audio_chunk)
# If we're getting silence after speech, the menu prompt is done
if result.audio_type == AudioClassification.SILENCE and transcript_parts:

View File

@@ -39,6 +39,7 @@ class RecordingService:
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:
@@ -101,8 +102,8 @@ class RecordingService:
self._active_recordings[call_id] = session
logger.info(f"🔴 Recording started: {call_id}{filepath_mixed}")
# Safety timeout
asyncio.create_task(
# 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}",
)
@@ -115,6 +116,14 @@ class RecordingService:
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}")