New modules shipped (from Linnet integration): - acoustic.py: AST (MIT/ast-finetuned-audioset-10-10-0.4593) replaces YAMNet stub; 527 AudioSet classes mapped to queue/speaker/environ/scene labels; _LABEL_MAP includes hold_music, ringback, DTMF, background_shift, AMD signal chain - accent.py: facebook/mms-lid-126 language ID → regional accent labels (en_gb, en_us, en_au, fr, es, de, zh, …); lazy-loaded, gated by CF_VOICE_ACCENT - privacy.py: compound privacy risk scorer — public_env, background_voices, nature scene, accent signals; returns 0–3 score without storing any audio - prosody.py: openSMILE-backed prosody extractor (sarcasm_risk, flat_f0_score, speech_rate, pitch_range); mock mode returns neutral values - dimensional.py: audeering/wav2vec2-large-robust-12-ft-emotion-msp-dim valence/arousal/dominance scorer; gated by CF_VOICE_DIMENSIONAL - trajectory.py: rolling buffer for arousal/valence deltas, trend detection (escalating/suppressed/stable), coherence scoring, suppression/reframe flags - telephony.py: TelephonyBackend Protocol + MockTelephonyBackend + SignalWireBackend + FreeSWITCHBackend; CallSession dataclass; make_telephony() factory - app.py: FastAPI service (port 8007) — /health + /classify; accepts base64 PCM chunks, returns full AudioEventOut including dimensional/prosody/accent fields - prefs.py: voice preference helpers (elcor_mode, confidence_threshold, whisper_model, elcor_prior_frames); cf-core and env-var fallback Tests: fix stale tests (YAMNetAcousticBackend → ASTAcousticBackend, scene field added to AcousticResult, speaker_at gap now resolves dominant speaker not UNKNOWN, make_io real path returns MicVoiceIO when sounddevice installed). 78 tests passing. Closes #2, #3.
141 lines
5.4 KiB
Python
141 lines
5.4 KiB
Python
import asyncio
|
|
import pytest
|
|
from cf_voice.telephony import (
|
|
CallSession,
|
|
MockTelephonyBackend,
|
|
TelephonyBackend,
|
|
make_telephony,
|
|
)
|
|
|
|
|
|
class TestCallSession:
|
|
def test_defaults(self):
|
|
s = CallSession(call_sid="sid_1", to="+15551234567", from_="+18005550000")
|
|
assert s.state == "dialing"
|
|
assert s.amd_result == "unknown"
|
|
assert s.duration_s == 0.0
|
|
assert s.error is None
|
|
|
|
def test_state_mutation(self):
|
|
s = CallSession(call_sid="sid_2", to="+1", from_="+2", state="in_progress")
|
|
s.state = "completed"
|
|
assert s.state == "completed"
|
|
|
|
|
|
class TestMockTelephonyBackend:
|
|
@pytest.mark.asyncio
|
|
async def test_dial_returns_session(self):
|
|
backend = MockTelephonyBackend()
|
|
session = await backend.dial("+15551234567", "+18005550000", "https://example.com/wh")
|
|
assert isinstance(session, CallSession)
|
|
assert session.call_sid.startswith("mock_sid_")
|
|
assert session.to == "+15551234567"
|
|
assert session.from_ == "+18005550000"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dial_transitions_to_in_progress(self):
|
|
backend = MockTelephonyBackend()
|
|
session = await backend.dial("+15551234567", "+18005550000", "https://x.com")
|
|
# give the background task a moment to transition
|
|
await asyncio.sleep(0.1)
|
|
assert session.state == "in_progress"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_amd_resolves_human(self):
|
|
backend = MockTelephonyBackend(amd_delay_s=0.05)
|
|
session = await backend.dial("+1555", "+1800", "https://x.com", amd=True)
|
|
await asyncio.sleep(0.2)
|
|
assert session.amd_result == "human"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_dtmf(self):
|
|
backend = MockTelephonyBackend()
|
|
session = await backend.dial("+1", "+2", "https://x.com")
|
|
# should not raise
|
|
await backend.send_dtmf(session.call_sid, "1234#")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_dtmf_unknown_sid_raises(self):
|
|
backend = MockTelephonyBackend()
|
|
with pytest.raises(KeyError):
|
|
await backend.send_dtmf("nonexistent_sid", "1")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bridge_updates_state(self):
|
|
backend = MockTelephonyBackend()
|
|
session = await backend.dial("+1", "+2", "https://x.com")
|
|
await backend.bridge(session.call_sid, "+15559999999")
|
|
assert session.state == "bridged"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_hangup_sets_completed(self):
|
|
backend = MockTelephonyBackend()
|
|
session = await backend.dial("+1", "+2", "https://x.com")
|
|
await backend.hangup(session.call_sid)
|
|
assert session.state == "completed"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_hangup_idempotent(self):
|
|
backend = MockTelephonyBackend()
|
|
session = await backend.dial("+1", "+2", "https://x.com")
|
|
await backend.hangup(session.call_sid)
|
|
await backend.hangup(session.call_sid)
|
|
assert session.state == "completed"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_announce_does_not_raise(self):
|
|
backend = MockTelephonyBackend()
|
|
session = await backend.dial("+1", "+2", "https://x.com")
|
|
await backend.announce(session.call_sid, "Hello, this is an automated assistant.")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_state(self):
|
|
backend = MockTelephonyBackend()
|
|
session = await backend.dial("+1", "+2", "https://x.com")
|
|
state = await backend.get_state(session.call_sid)
|
|
assert state in ("ringing", "in_progress", "dialing")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_calls_unique_sids(self):
|
|
backend = MockTelephonyBackend()
|
|
s1 = await backend.dial("+1", "+2", "https://x.com")
|
|
s2 = await backend.dial("+3", "+4", "https://x.com")
|
|
assert s1.call_sid != s2.call_sid
|
|
|
|
def test_isinstance_protocol(self):
|
|
backend = MockTelephonyBackend()
|
|
assert isinstance(backend, TelephonyBackend)
|
|
|
|
|
|
class TestMakeTelephony:
|
|
def test_mock_flag(self):
|
|
backend = make_telephony(mock=True)
|
|
assert isinstance(backend, MockTelephonyBackend)
|
|
|
|
def test_mock_env(self, monkeypatch):
|
|
monkeypatch.setenv("CF_VOICE_MOCK", "1")
|
|
backend = make_telephony()
|
|
assert isinstance(backend, MockTelephonyBackend)
|
|
|
|
def test_no_config_raises(self, monkeypatch):
|
|
monkeypatch.delenv("CF_VOICE_MOCK", raising=False)
|
|
monkeypatch.delenv("CF_SW_PROJECT_ID", raising=False)
|
|
monkeypatch.delenv("CF_ESL_PASSWORD", raising=False)
|
|
with pytest.raises(RuntimeError, match="No telephony backend configured"):
|
|
make_telephony()
|
|
|
|
def test_signalwire_selected_by_env(self, monkeypatch):
|
|
monkeypatch.delenv("CF_VOICE_MOCK", raising=False)
|
|
monkeypatch.setenv("CF_SW_PROJECT_ID", "proj_123")
|
|
# SignalWireBackend will raise ImportError (signalwire SDK not installed)
|
|
# but only at instantiation — make_telephony should call the constructor
|
|
with pytest.raises((ImportError, RuntimeError)):
|
|
make_telephony()
|
|
|
|
def test_freeswitch_selected_by_env(self, monkeypatch):
|
|
monkeypatch.delenv("CF_VOICE_MOCK", raising=False)
|
|
monkeypatch.delenv("CF_SW_PROJECT_ID", raising=False)
|
|
monkeypatch.setenv("CF_ESL_PASSWORD", "s3cret")
|
|
# FreeSWITCHBackend will raise ImportError (ESL not installed)
|
|
with pytest.raises((ImportError, RuntimeError)):
|
|
make_telephony()
|