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