cf-voice/tests/test_telephony.py
pyr0ball 24f04b67db feat: full voice pipeline — AST acoustic, accent, privacy, prosody, dimensional, trajectory, telephony, FastAPI app
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.
2026-04-18 22:36:58 -07:00

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()