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.
177 lines
6.2 KiB
Python
177 lines
6.2 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from typing import Literal
|
|
|
|
PitchRegister = Literal[
|
|
"very_low", "low", "mid_low", "mid", "mid_high", "high", "very_high", "ultra_high"
|
|
]
|
|
PaceBaseline = Literal["very_slow", "slow", "moderate", "brisk", "fast", "rapid"]
|
|
Energy = Literal[
|
|
"whispered", "subdued", "soft_spoken", "measured", "assertive",
|
|
"commanding", "volatile", "explosive"
|
|
]
|
|
Timbre = Literal[
|
|
"warm", "breathy", "nasal", "gravelly", "clear", "resonant", "thin",
|
|
"metallic", "velvety"
|
|
]
|
|
Texture = Literal["smooth", "raspy", "clipped", "melodic", "monotone", "staccato", "lilting"]
|
|
Formality = Literal["casual", "conversational", "formal", "archaic", "military", "academic"]
|
|
|
|
VALID_PITCH_REGISTERS = set(PitchRegister.__args__) # type: ignore[attr-defined]
|
|
VALID_PACE_BASELINES = set(PaceBaseline.__args__) # type: ignore[attr-defined]
|
|
VALID_ENERGIES = set(Energy.__args__) # type: ignore[attr-defined]
|
|
VALID_TIMBRES = set(Timbre.__args__) # type: ignore[attr-defined]
|
|
VALID_TEXTURES = set(Texture.__args__) # type: ignore[attr-defined]
|
|
VALID_FORMALITIES = set(Formality.__args__) # type: ignore[attr-defined]
|
|
|
|
TAXONOMY_ENUMS = {
|
|
"pitch_register": VALID_PITCH_REGISTERS,
|
|
"pace_baseline": VALID_PACE_BASELINES,
|
|
"energy": VALID_ENERGIES,
|
|
"timbre": VALID_TIMBRES,
|
|
"texture": VALID_TEXTURES,
|
|
"formality": VALID_FORMALITIES,
|
|
}
|
|
|
|
TAXONOMY_DEFAULTS = {
|
|
"pitch_register": "mid",
|
|
"pace_baseline": "moderate",
|
|
"energy": "measured",
|
|
"timbre": "clear",
|
|
"texture": "smooth",
|
|
"formality": "conversational",
|
|
}
|
|
|
|
HASH_VARY_PITCH: dict[str, list[str]] = {
|
|
"Male": ["low", "mid_low", "mid", "mid_high"],
|
|
"Female": ["mid_high", "high", "mid", "mid_low"],
|
|
"Unknown": ["mid_low", "mid", "mid_high", "low"],
|
|
}
|
|
|
|
HASH_VARY_ENERGY: list[str] = [
|
|
"subdued", "soft_spoken", "measured", "assertive", "commanding", "volatile",
|
|
"whispered", "explosive",
|
|
]
|
|
|
|
HASH_VARY_TIMBRE: dict[str, list[str]] = {
|
|
"Male": ["clear", "resonant", "gravelly", "warm", "metallic"],
|
|
"Female": ["warm", "clear", "breathy", "velvety", "resonant"],
|
|
"Unknown": ["clear", "resonant", "warm", "thin", "breathy"],
|
|
}
|
|
|
|
HASH_VARY_TEXTURE: list[str] = [
|
|
"smooth", "raspy", "clipped", "melodic", "monotone", "staccato", "lilting",
|
|
]
|
|
|
|
HASH_VARY_FORMALITY: list[str] = [
|
|
"casual", "conversational", "formal", "archaic", "military", "academic",
|
|
]
|
|
|
|
|
|
@dataclass
|
|
class CharacterProfile:
|
|
name: str
|
|
aliases: list[str] = field(default_factory=list)
|
|
gender: str | None = None
|
|
age_range: str | None = None
|
|
is_narrator: bool = False
|
|
personality: str | None = None
|
|
voice_description: str | None = None
|
|
emotion_tendencies: list[str] = field(default_factory=list)
|
|
speech_patterns: str | None = None
|
|
narrator_style: str | None = None
|
|
|
|
pitch_register: PitchRegister = "mid"
|
|
pace_baseline: PaceBaseline = "moderate"
|
|
energy: Energy = "measured"
|
|
timbre: Timbre = "clear"
|
|
texture: Texture = "smooth"
|
|
formality: Formality = "conversational"
|
|
emotional_baseline: str = "neutral_calm"
|
|
distinguishing_trait: str | None = None
|
|
accent_marker: str = "none"
|
|
accent_strength: str = "medium"
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"name": self.name,
|
|
"aliases": self.aliases,
|
|
"gender": self.gender,
|
|
"age_range": self.age_range,
|
|
"is_narrator": self.is_narrator,
|
|
"personality": self.personality,
|
|
"voice_description": self.voice_description,
|
|
"emotion_tendencies": self.emotion_tendencies,
|
|
"speech_patterns": self.speech_patterns,
|
|
"narrator_style": self.narrator_style,
|
|
"pitch_register": self.pitch_register,
|
|
"pace_baseline": self.pace_baseline,
|
|
"energy": self.energy,
|
|
"timbre": self.timbre,
|
|
"texture": self.texture,
|
|
"formality": self.formality,
|
|
"emotional_baseline": self.emotional_baseline,
|
|
"distinguishing_trait": self.distinguishing_trait,
|
|
"accent_marker": self.accent_marker,
|
|
"accent_strength": self.accent_strength,
|
|
}
|
|
|
|
def taxonomy_tuple(self) -> tuple[str, str, str, str, str, str]:
|
|
return (
|
|
self.pitch_register,
|
|
self.pace_baseline,
|
|
self.energy,
|
|
self.timbre,
|
|
self.texture,
|
|
self.formality,
|
|
)
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: dict) -> CharacterProfile:
|
|
validated = {}
|
|
for field_name, valid_set in TAXONOMY_ENUMS.items():
|
|
raw = data.get(field_name, TAXONOMY_DEFAULTS[field_name])
|
|
if raw in valid_set:
|
|
validated[field_name] = raw
|
|
else:
|
|
validated[field_name] = TAXONOMY_DEFAULTS[field_name]
|
|
return cls(
|
|
name=data.get("name", ""),
|
|
aliases=data.get("aliases", []),
|
|
gender=data.get("gender"),
|
|
age_range=data.get("age_range"),
|
|
is_narrator=data.get("is_narrator", False),
|
|
personality=data.get("personality"),
|
|
voice_description=data.get("voice_description"),
|
|
emotion_tendencies=data.get("emotion_tendencies", []),
|
|
speech_patterns=data.get("speech_patterns"),
|
|
narrator_style=data.get("narrator_style"),
|
|
**validated,
|
|
emotional_baseline=data.get("emotional_baseline", "neutral_calm"),
|
|
distinguishing_trait=data.get("distinguishing_trait"),
|
|
accent_marker=data.get("accent_marker", "none"),
|
|
accent_strength=data.get("accent_strength", "medium"),
|
|
)
|
|
|
|
|
|
NARRATOR_DEFAULT_PROFILE = CharacterProfile(
|
|
name="narrator",
|
|
is_narrator=True,
|
|
pitch_register="mid",
|
|
pace_baseline="moderate",
|
|
energy="measured",
|
|
timbre="clear",
|
|
texture="smooth",
|
|
formality="conversational",
|
|
emotional_baseline="neutral_calm",
|
|
distinguishing_trait="even, unhurried, minimal emphasis",
|
|
accent_marker="none",
|
|
voice_description=(
|
|
"A calm, clear, even-toned narrator. Minimal emotional coloring, "
|
|
"no shouting, no dramatic swings in pace or pitch. Reads "
|
|
"descriptive prose steadily regardless of how dramatic the "
|
|
"events being described are."
|
|
),
|
|
)
|