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.
119 lines
4.6 KiB
Python
119 lines
4.6 KiB
Python
import pytest
|
|
from cf_voice.acoustic import (
|
|
AcousticBackend,
|
|
AcousticResult,
|
|
ASTAcousticBackend,
|
|
MockAcousticBackend,
|
|
make_acoustic,
|
|
)
|
|
from cf_voice.events import AudioEvent
|
|
|
|
|
|
class TestAcousticResult:
|
|
def test_fields(self):
|
|
evt = AudioEvent(timestamp=1.0, event_type="queue", label="ringback", confidence=0.9)
|
|
result = AcousticResult(queue=evt, speaker=None, environ=None, scene=None, timestamp=1.0)
|
|
assert result.queue.label == "ringback"
|
|
assert result.speaker is None
|
|
assert result.environ is None
|
|
assert result.scene is None
|
|
|
|
|
|
class TestMockAcousticBackend:
|
|
def test_classify_returns_result(self):
|
|
backend = MockAcousticBackend(seed=0)
|
|
result = backend.classify_window(b"", timestamp=0.0)
|
|
assert isinstance(result, AcousticResult)
|
|
assert result.timestamp == 0.0
|
|
|
|
def test_all_events_present(self):
|
|
backend = MockAcousticBackend(seed=1)
|
|
result = backend.classify_window(b"", timestamp=1.0)
|
|
assert result.queue is not None
|
|
assert result.speaker is not None
|
|
assert result.environ is not None
|
|
assert result.scene is not None
|
|
|
|
def test_event_types_correct(self):
|
|
backend = MockAcousticBackend(seed=2)
|
|
result = backend.classify_window(b"", timestamp=2.0)
|
|
assert result.queue.event_type == "queue"
|
|
assert result.speaker.event_type == "speaker"
|
|
assert result.environ.event_type == "environ"
|
|
assert result.scene.event_type == "scene"
|
|
|
|
def test_confidence_in_range(self):
|
|
backend = MockAcousticBackend(seed=3)
|
|
for _ in range(5):
|
|
result = backend.classify_window(b"", timestamp=0.0)
|
|
assert 0.0 <= result.queue.confidence <= 1.0
|
|
assert 0.0 <= result.speaker.confidence <= 1.0
|
|
assert 0.0 <= result.environ.confidence <= 1.0
|
|
assert 0.0 <= result.scene.confidence <= 1.0
|
|
|
|
def test_lifecycle_advances(self):
|
|
"""Phases should change after their duration elapses."""
|
|
import time
|
|
backend = MockAcousticBackend(seed=42)
|
|
# Force phase to advance by manipulating phase_start
|
|
backend._phase_start -= 1000 # pretend 1000s elapsed
|
|
result = backend.classify_window(b"", timestamp=0.0)
|
|
# Should have advanced — just verify it doesn't crash and returns valid
|
|
assert result.queue.label in (
|
|
"hold_music", "silence", "ringback", "busy", "dead_air", "dtmf_tone"
|
|
)
|
|
|
|
def test_isinstance_protocol(self):
|
|
backend = MockAcousticBackend()
|
|
assert isinstance(backend, AcousticBackend)
|
|
|
|
def test_deterministic_with_seed(self):
|
|
b1 = MockAcousticBackend(seed=99)
|
|
b2 = MockAcousticBackend(seed=99)
|
|
r1 = b1.classify_window(b"", timestamp=0.0)
|
|
r2 = b2.classify_window(b"", timestamp=0.0)
|
|
assert r1.queue.label == r2.queue.label
|
|
assert r1.queue.confidence == r2.queue.confidence
|
|
|
|
|
|
class TestASTAcousticBackend:
|
|
def test_raises_import_error_without_deps(self, monkeypatch):
|
|
"""ASTAcousticBackend should raise ImportError when transformers is unavailable."""
|
|
import builtins
|
|
real_import = builtins.__import__
|
|
|
|
def mock_import(name, *args, **kwargs):
|
|
if name in ("transformers",):
|
|
raise ImportError(f"Mocked: {name} not available")
|
|
return real_import(name, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(builtins, "__import__", mock_import)
|
|
with pytest.raises(ImportError, match="transformers"):
|
|
ASTAcousticBackend()
|
|
|
|
|
|
class TestMakeAcoustic:
|
|
def test_mock_flag(self):
|
|
backend = make_acoustic(mock=True)
|
|
assert isinstance(backend, MockAcousticBackend)
|
|
|
|
def test_mock_env(self, monkeypatch):
|
|
monkeypatch.setenv("CF_VOICE_MOCK", "1")
|
|
backend = make_acoustic()
|
|
assert isinstance(backend, MockAcousticBackend)
|
|
|
|
def test_real_falls_back_to_mock_without_deps(self, monkeypatch, capsys):
|
|
"""make_acoustic(mock=False) falls back to mock when deps are missing."""
|
|
import builtins
|
|
real_import = builtins.__import__
|
|
|
|
def mock_import(name, *args, **kwargs):
|
|
if name in ("transformers",):
|
|
raise ImportError(f"Mocked: {name} not available")
|
|
return real_import(name, *args, **kwargs)
|
|
|
|
monkeypatch.delenv("CF_VOICE_MOCK", raising=False)
|
|
monkeypatch.setattr(builtins, "__import__", mock_import)
|
|
backend = make_acoustic(mock=False)
|
|
# Should fall back gracefully, never raise
|
|
assert isinstance(backend, MockAcousticBackend)
|