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.
159 lines
6.3 KiB
Python
159 lines
6.3 KiB
Python
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from umbraaudio.voices.voice_profile_manager import (
|
|
build_profile_from_speaker,
|
|
get_profile_for_speaker,
|
|
)
|
|
|
|
|
|
def _speaker(**overrides: object) -> dict[str, Any]:
|
|
base: dict[str, Any] = {
|
|
"primary_name": "Kainda",
|
|
"aliases": [],
|
|
"demographics": {"type": "Human", "gender": "Female", "age": "Unknown", "pitch": ""},
|
|
"vocal_traits": [],
|
|
"communication_style": {"primary_register": "Casual", "style_shifts_or_notes": ""},
|
|
"description": "",
|
|
"is_narrator": False,
|
|
"accent": "none",
|
|
}
|
|
base.update(overrides)
|
|
return base
|
|
|
|
|
|
def _demographics(speaker: dict[str, Any]) -> dict[str, Any]:
|
|
return speaker["demographics"] # type: ignore[no-any-return]
|
|
|
|
|
|
class TestBuildProfileFromSpeaker:
|
|
def test_default_infers_pitch_from_gender(self) -> None:
|
|
profile = build_profile_from_speaker(_speaker())
|
|
assert profile.pitch_register in ("mid_low", "mid", "mid_high", "low", "high")
|
|
|
|
def test_explicit_pitch_override_wins(self) -> None:
|
|
speaker = _speaker()
|
|
_demographics(speaker)["pitch"] = "ultra_high"
|
|
profile = build_profile_from_speaker(speaker)
|
|
assert profile.pitch_register == "ultra_high"
|
|
|
|
def test_invalid_explicit_pitch_ignored(self) -> None:
|
|
speaker = _speaker()
|
|
_demographics(speaker)["pitch"] = "baritone-ish"
|
|
profile = build_profile_from_speaker(speaker)
|
|
assert profile.pitch_register in (
|
|
"very_low",
|
|
"low",
|
|
"mid_low",
|
|
"mid",
|
|
"mid_high",
|
|
"high",
|
|
"very_high",
|
|
"ultra_high",
|
|
)
|
|
|
|
def test_age_sets_age_range(self) -> None:
|
|
speaker = _speaker()
|
|
_demographics(speaker)["age"] = "Elderly"
|
|
profile = build_profile_from_speaker(speaker)
|
|
assert profile.age_range == "Elderly"
|
|
|
|
def test_unknown_age_leaves_age_range_none(self) -> None:
|
|
profile = build_profile_from_speaker(_speaker())
|
|
assert profile.age_range is None
|
|
|
|
def test_child_age_raises_pitch(self) -> None:
|
|
speaker = _speaker()
|
|
_demographics(speaker)["age"] = "Child"
|
|
profile = build_profile_from_speaker(speaker)
|
|
assert profile.pitch_register in ("high", "very_high", "ultra_high")
|
|
|
|
def test_child_age_appears_in_description(self) -> None:
|
|
speaker = _speaker()
|
|
_demographics(speaker)["age"] = "Child"
|
|
profile = build_profile_from_speaker(speaker)
|
|
assert "young child" in (profile.voice_description or "")
|
|
|
|
def test_elderly_pitch_adjusts_when_mid(self) -> None:
|
|
speaker = _speaker()
|
|
_demographics(speaker)["age"] = "Elderly"
|
|
_demographics(speaker)["gender"] = "Male"
|
|
profile = build_profile_from_speaker(speaker)
|
|
assert profile.pitch_register == "low"
|
|
|
|
def test_explicit_pitch_beats_age_adjustment(self) -> None:
|
|
speaker = _speaker()
|
|
_demographics(speaker)["age"] = "Child"
|
|
_demographics(speaker)["pitch"] = "very_low"
|
|
profile = build_profile_from_speaker(speaker)
|
|
assert profile.pitch_register == "very_low"
|
|
|
|
def test_accent_appears_in_description(self) -> None:
|
|
speaker = _speaker(accent="australian")
|
|
profile = build_profile_from_speaker(speaker)
|
|
assert profile.accent_marker == "australian"
|
|
assert "an australian accent" in (profile.voice_description or "")
|
|
|
|
def test_consonant_accent_uses_proper_article(self) -> None:
|
|
speaker = _speaker(accent="british")
|
|
profile = build_profile_from_speaker(speaker)
|
|
assert "a british accent" in (profile.voice_description or "")
|
|
|
|
def test_no_accent_omits_accent_phrase(self) -> None:
|
|
profile = build_profile_from_speaker(_speaker())
|
|
assert profile.accent_marker == "none"
|
|
assert "accent" not in (profile.voice_description or "")
|
|
|
|
def test_accent_survives_force_rebuild(self, temp_dir: Path) -> None:
|
|
speaker = _speaker(accent="australian")
|
|
profile = get_profile_for_speaker("Kainda", speaker, temp_dir, force_rebuild=True)
|
|
assert profile.accent_marker == "australian"
|
|
assert "an australian accent" in (profile.voice_description or "")
|
|
|
|
def test_narrator_uses_speaker_accent(self) -> None:
|
|
speaker = _speaker(is_narrator=True, accent="american")
|
|
profile = build_profile_from_speaker(speaker)
|
|
assert profile.is_narrator is True
|
|
assert profile.accent_marker == "american"
|
|
assert "an american accent" in (profile.voice_description or "")
|
|
|
|
def test_narrator_defaults_to_american_accent(self) -> None:
|
|
speaker = _speaker(is_narrator=True, accent="none")
|
|
profile = build_profile_from_speaker(speaker)
|
|
assert profile.accent_marker == "american"
|
|
assert "an american accent" in (profile.voice_description or "")
|
|
|
|
def test_narrator_with_explicit_non_default_accent(self) -> None:
|
|
speaker = _speaker(is_narrator=True, accent="british")
|
|
profile = build_profile_from_speaker(speaker)
|
|
assert profile.accent_marker == "british"
|
|
assert "a british accent" in (profile.voice_description or "")
|
|
|
|
|
|
class TestGetProfileForSpeaker:
|
|
def test_cached_profile_returned_without_rebuild(self, temp_dir: Path) -> None:
|
|
speaker = _speaker()
|
|
_demographics(speaker)["pitch"] = "high"
|
|
first = get_profile_for_speaker("Kainda", speaker, temp_dir)
|
|
assert first.pitch_register == "high"
|
|
|
|
# Change the speaker data; cache should win without force_rebuild
|
|
_demographics(speaker)["pitch"] = "low"
|
|
cached = get_profile_for_speaker("Kainda", speaker, temp_dir)
|
|
assert cached.pitch_register == "high"
|
|
|
|
def test_force_rebuild_reflects_speaker_changes(self, temp_dir: Path) -> None:
|
|
speaker = _speaker()
|
|
_demographics(speaker)["pitch"] = "high"
|
|
get_profile_for_speaker("Kainda", speaker, temp_dir)
|
|
|
|
_demographics(speaker)["pitch"] = "very_low"
|
|
rebuilt = get_profile_for_speaker("Kainda", speaker, temp_dir, force_rebuild=True)
|
|
assert rebuilt.pitch_register == "very_low"
|
|
|
|
# Cache file updated for subsequent reads
|
|
again = get_profile_for_speaker("Kainda", speaker, temp_dir)
|
|
assert again.pitch_register == "very_low"
|