cf-voice/tests/test_acoustic.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

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)