Files
UmbraAudio/tests/test_voice_analysis.py
B.D. Murphy c86e323d7a Initial commit: UmbraAudio audiobook generation program
Quality-first multi-pass audiobook generation from English manuscripts.

Includes:
- umbraaudio/ core package (pipeline, config, voices, web review UI)
- scripts/ CLI utilities + model download script
- tts_server/ remote TTS server
- specs/ design docs, tests/, pyproject.toml + uv.lock
- Qwen3-TTS git submodule (pinned 022e286) with local demo.py UI patch
  saved at scripts/qwen3_tts_demo_umbra_patch.patch
- .env with runtime config/secrets

Excluded (see .gitignore): models/ (downloaded via scripts/download_models.sh),
output/, logs/, .venv, .umbra runtime data, and book projects
(DealHunterShip_*), which are committed separately.
2026-08-30 07:58:24 -07:00

163 lines
6.0 KiB
Python

from __future__ import annotations
import json
import math
import numpy as np
import pytest
import soundfile as sf
from umbraaudio.analysis.voice_analysis import (
VoiceAnalysisEngine,
_acoustic_vector,
_analyze_audio_file,
load_analysis_status,
run_voice_analysis_in_background,
)
def _write_sine(path, freq: float, seconds: float, sr: int = 24000) -> None:
t = np.linspace(0, seconds, int(sr * seconds), endpoint=False)
y = (0.1 * np.sin(2 * math.pi * freq * t)).astype(np.float32)
sf.write(str(path), y, sr)
@pytest.fixture
def refs_dir(temp_dir):
d = temp_dir / "voices"
d.mkdir()
return d
@pytest.fixture
def engine(temp_dir, refs_dir):
output_dir = temp_dir / "analysis"
return VoiceAnalysisEngine(refs_dir=refs_dir, output_dir=output_dir, min_duration_s=3.0)
class TestAnalyzeAudio:
def test_computes_features(self, temp_dir):
path = temp_dir / "alice_ref.wav"
_write_sine(path, 200.0, 5.0)
features = _analyze_audio_file(path, 24000)
assert features.duration == pytest.approx(5.0, abs=0.1)
assert features.sample_rate == 24000
assert features.mean_pitch_hz is not None
assert features.mean_pitch_hz == pytest.approx(200.0, abs=30.0)
assert features.rms > 0.0
class TestEngine:
def test_analyze_empty_dir(self, engine):
report = engine.analyze()
assert report["summary"]["total"] == 0
assert report["passed"] is False
assert engine.report_path.exists()
def test_analyze_single_voice(self, engine, temp_dir):
_write_sine(engine.refs_dir / "alice_ref.wav", 200.0, 5.0)
report = engine.analyze()
assert report["summary"]["total"] == 1
assert "alice" in report["voices"]
voice = report["voices"]["alice"]
assert voice["requirements"]["duration"]["passed"]
assert voice["requirements"]["sample_rate"]["passed"]
assert voice["requirements"]["energy"]["passed"]
assert voice["requirements"]["uniqueness"]["passed"]
def test_analyze_two_distinct_voices(self, engine):
_write_sine(engine.refs_dir / "alice_ref.wav", 180.0, 5.0)
_write_sine(engine.refs_dir / "bob_ref.wav", 260.0, 5.0)
report = engine.analyze()
assert report["summary"]["total"] == 2
assert report["summary"]["near_duplicate_count"] == 0
assert report["passed"] is True
def test_speaker_names_filter(self, engine, temp_dir):
_write_sine(engine.refs_dir / "alice_ref.wav", 180.0, 5.0)
_write_sine(engine.refs_dir / "stale_ref.wav", 220.0, 5.0)
engine.speaker_names = {"Alice"}
report = engine.analyze()
assert report["summary"]["total"] == 1
assert "alice" in report["voices"]
assert "stale" not in report["voices"]
def test_short_file_fails_duration(self, engine):
_write_sine(engine.refs_dir / "alice_ref.wav", 200.0, 1.0)
report = engine.analyze()
assert report["voices"]["alice"]["requirements"]["duration"]["passed"] is False
def test_report_persisted(self, engine):
_write_sine(engine.refs_dir / "alice_ref.wav", 200.0, 5.0)
engine.analyze()
saved = json.loads(engine.report_path.read_text())
assert saved["summary"]["total"] == 1
assert saved["passed"] is True
def test_load_report(self, engine):
_write_sine(engine.refs_dir / "alice_ref.wav", 200.0, 5.0)
engine.analyze()
loaded = engine.load_report()
assert loaded is not None
assert loaded["summary"]["total"] == 1
class TestNearDuplicates:
def test_similar_voices_flagged(self, engine):
_write_sine(engine.refs_dir / "alice_ref.wav", 200.0, 5.0)
_write_sine(engine.refs_dir / "alice2_ref.wav", 200.0, 5.0)
report = engine.analyze()
assert report["summary"]["near_duplicate_count"] >= 1
def test_near_duplicate_threshold_respected(self, engine):
engine.near_duplicate_threshold = 1.01
_write_sine(engine.refs_dir / "alice_ref.wav", 200.0, 5.0)
_write_sine(engine.refs_dir / "bob_ref.wav", 260.0, 5.0)
report = engine.analyze()
assert report["summary"]["near_duplicate_count"] == 0
class TestAcousticFallback:
def test_acoustic_vector(self, engine, temp_dir):
_write_sine(temp_dir / "alice_ref.wav", 200.0, 5.0)
features = _analyze_audio_file(temp_dir / "alice_ref.wav", 24000)
vec = _acoustic_vector(features)
assert vec.shape == (6,)
assert vec.dtype == np.float32
assert vec[0] == pytest.approx(features.mean_pitch_hz or 0.0)
assert vec[1] == pytest.approx(features.pitch_std_hz or 0.0)
def test_embedding_failure_falls_back_to_acoustic(self, engine, monkeypatch):
_write_sine(engine.refs_dir / "alice_ref.wav", 180.0, 5.0)
_write_sine(engine.refs_dir / "bob_ref.wav", 260.0, 5.0)
def _boom(self):
raise RuntimeError("encoder unavailable")
monkeypatch.setattr(
"umbraaudio.analysis.voice_analysis.VoiceAnalysisEngine._get_embeddings",
_boom,
)
report = engine.analyze()
assert report["summary"]["total"] == 2
# Fallback must still produce a populated similarity matrix for both voices.
alice = report["voices"]["alice"]
assert alice["requirements"]["uniqueness"]["nearest_neighbor"] == "bob"
class TestBackgroundRun:
def test_runs_and_writes_status(self, temp_dir, refs_dir):
output_dir = temp_dir / "analysis"
_write_sine(refs_dir / "alice_ref.wav", 200.0, 5.0)
run_voice_analysis_in_background(
refs_dir=refs_dir, output_dir=output_dir, speaker_names={"Alice"}
)
import time
report_path = output_dir / "voice_analysis.json"
deadline = time.time() + 30
while time.time() < deadline and not report_path.exists():
time.sleep(0.2)
assert report_path.exists()
assert load_analysis_status(output_dir)["status"] == "idle"