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.
163 lines
6.0 KiB
Python
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"
|