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