Files
UmbraAudio/umbraaudio/voices/voice_profile_manager.py
B.D. Murphy a172610b0e Add voice library, embedding-mode generation, and webUI improvements
- Add cross-project voice/name library (library.py + webUI library tabs)
- chapter_builder: embedding-mode generation + auto-load TTS base model in preflight
- qwen_tts: vmatrix capture, embedding/voice-prompt refinements
- registry: vmatrix_path column + migration for approved voices
- webUI: speakers list, chapter audio detail, proper names updates
- docs: update voice generation and tts_server README
2026-09-02 17:33:15 -07:00

740 lines
26 KiB
Python

from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import Any
from umbraaudio.analysis import (
HASH_VARY_ENERGY,
HASH_VARY_PITCH,
HASH_VARY_TEXTURE,
HASH_VARY_TIMBRE,
TAXONOMY_ENUMS,
VALID_PITCH_REGISTERS,
CharacterProfile,
)
logger = logging.getLogger(__name__)
TAXONOMY_KEYS = list(TAXONOMY_ENUMS.keys())
TRAIT_TO_PITCH: dict[str, str] = {
"deep": "low",
"low": "low",
"baritone": "low",
"bass": "very_low",
"high": "high",
"high-pitched": "high",
"shrill": "very_high",
"squeaky": "very_high",
"monotone": "mid",
"flat": "mid",
"soft": "low",
"growl": "low",
"rumbling": "very_low",
"light": "high",
"airy": "high",
"piercing": "very_high",
"ultra-high": "ultra_high",
"ultra high": "ultra_high",
"soprano": "ultra_high",
"tenor": "mid_high",
"alto": "mid_low",
"whispery": "mid",
"breathy": "mid_high",
}
TRAIT_TO_ENERGY: dict[str, str] = {
"whispered": "whispered",
"hushed": "whispered",
"murmured": "whispered",
"subdued": "subdued",
"quiet": "subdued",
"restrained": "subdued",
"soft": "soft_spoken",
"whisper": "soft_spoken",
"gentle": "soft_spoken",
"warm": "soft_spoken",
"calm": "measured",
"even": "measured",
"steady": "measured",
"measured": "measured",
"neutral": "measured",
"polite": "measured",
"assertive": "assertive",
"confident": "assertive",
"firm": "assertive",
"sassy": "assertive",
"commanding": "commanding",
"authoritative": "commanding",
"powerful": "commanding",
"dominant": "commanding",
"aggressive": "volatile",
"angry": "volatile",
"intense": "volatile",
"frantic": "volatile",
"volatile": "volatile",
"harsh": "volatile",
"yelling": "volatile",
"shouting": "volatile",
"energetic": "assertive",
"excited": "assertive",
"demanding": "commanding",
"determined": "assertive",
"desperate": "volatile",
"fearful": "subdued",
"nervous": "subdued",
"anxious": "soft_spoken",
"explosive": "explosive",
"thundering": "explosive",
"deafening": "explosive",
"eruptive": "explosive",
}
TRAIT_TO_TIMBRE: dict[str, str] = {
"warm": "warm",
"rich": "warm",
"full-bodied": "warm",
"buttery": "warm",
"velvety": "velvety",
"velvet": "velvety",
"lush": "velvety",
"silky": "velvety",
"breathy": "breathy",
"whispery": "breathy",
"sighing": "breathy",
"airy": "breathy",
"nasal": "nasal",
"twangy": "nasal",
"reedy": "nasal",
"pinched": "nasal",
"gravelly": "gravelly",
"rough": "gravelly",
"gruff": "gravelly",
"hoarse": "gravelly",
"husky": "gravelly",
"gritty": "gravelly",
"clear": "clear",
"clean": "clear",
"bright": "clear",
"bell-like": "clear",
"crystalline": "clear",
"resonant": "resonant",
"booming": "resonant",
"sonorous": "resonant",
"echoing": "resonant",
"thin": "thin",
"small": "thin",
"metallic": "metallic",
"robotic": "metallic",
"mechanical": "metallic",
"synthetic": "metallic",
"tinny": "metallic",
}
TRAIT_TO_TEXTURE: dict[str, str] = {
"smooth": "smooth",
"silky": "smooth",
"polished": "smooth",
"polite": "smooth",
"raspy": "raspy",
"rough": "raspy",
"gravelly": "raspy",
"gruff": "raspy",
"scratchy": "raspy",
"harsh": "raspy",
"clipped": "clipped",
"crisp": "clipped",
"precise": "clipped",
"staccato": "staccato",
"choppy": "staccato",
"abrupt": "staccato",
"halting": "staccato",
"melodic": "melodic",
"sing-song": "melodic",
"musical": "melodic",
"lilting": "lilting",
"bouncy": "lilting",
"rhythmic": "lilting",
"monotone": "monotone",
"flat": "monotone",
"droning": "monotone",
"emotionless": "monotone",
"robotic": "monotone",
"mechanical": "monotone",
"synthetic": "monotone",
"neutral": "smooth",
}
REGISTER_TO_FORMALITY: dict[str, str] = {
"Formal": "formal",
"Formal/Clinical": "formal",
"Formal/Strict": "formal",
"Formal/Professional": "formal",
"Formal/Authoritative": "formal",
"Formal/Functional": "formal",
"Formal/Military": "formal",
"Formal/Warning": "formal",
"Formal/Informational": "formal",
"Formal/Hesitant": "formal",
"Formal/Inquisitive": "formal",
"Formal/Irritated": "formal",
"Formal/Polite": "formal",
"Casual": "casual",
"Casual/Functional": "casual",
"Casual/Complaining": "casual",
"Casual/Aggressive": "casual",
"Casual/Business": "casual",
"Conversational": "conversational",
"Authoritative": "formal",
"Authoritative/Direct": "formal",
"Authoritative/Instructional": "formal",
"Authoritative/Professional": "formal",
"Aggressive/Hostile": "casual",
"Aggressive/Authoritative": "formal",
"Automated": "formal",
"Clinical/Frustrated": "formal",
"Military/Aggressive": "formal",
"Frantic/Angry": "casual",
"Hostile/Aggressive": "casual",
"Nervous/Submissive": "conversational",
"Nervous/Accusatory": "casual",
"Logical/Pragmatic": "conversational",
"Observational/Analytical": "conversational",
"Cautious/Risk-Averse": "formal",
"Flirtatious/Formal": "conversational",
"Informative/Observational": "conversational",
"Informational": "conversational",
"Strained": "casual",
"Formal/Subservient": "formal",
}
REGISTER_TO_PACE: dict[str, str] = {
"Formal": "moderate",
"Formal/Strict": "slow",
"Formal/Military": "moderate",
"Casual": "moderate",
"Automated": "moderate",
"Frantic": "rapid",
"Frantic/Angry": "rapid",
"Hostile/Aggressive": "fast",
"Clinical/Frustrated": "brisk",
"Nervous/Submissive": "moderate",
"Formal/Clinical": "moderate",
"Authoritative/Direct": "moderate",
"Cautious/Risk-Averse": "slow",
"Flirtatious/Formal": "moderate",
"Aggressive/Hostile": "fast",
"Formal/Informational": "moderate",
"Formal/Polite": "slow",
"Casual/Aggressive": "fast",
"Strained": "slow",
"Military/Aggressive": "fast",
"Logical/Pragmatic": "moderate",
"Observational/Analytical": "slow",
"Informational": "moderate",
"Formal/Irritated": "brisk",
"Nervous/Accusatory": "fast",
"Formal/Functional": "moderate",
"Informative/Observational": "moderate",
"Formal/Warning": "moderate",
"Formal/Professional": "moderate",
"Formal/Authoritative": "moderate",
"Formal/Hesitant": "slow",
"Formal/Inquisitive": "moderate",
"Formal/Subservient": "moderate",
"Authoritative/Instructional": "moderate",
"Authoritative/Professional": "moderate",
"Aggressive/Authoritative": "fast",
"Casual/Functional": "moderate",
"Casual/Business": "moderate",
"Casual/Complaining": "moderate",
}
DEMOGRAPHIC_TYPE_TO_DESC: dict[str, str] = {
"AI/Computer": "artificial intelligence",
"Robot": "robot",
"Human/Robot": "cyborg",
"Human": "human",
}
AGE_TO_PITCH_ADJUST: dict[str, str] = {
"Child": "high",
"Young": "mid_high",
"Middle-aged": "mid_low",
"Elderly": "low",
"Old": "low",
}
AGE_DESCRIPTORS: dict[str, str] = {
"Child": "sounding like a young child",
"Young": "with a youthful quality",
"Middle-aged": "with a middle-aged quality",
"Elderly": "sounding elderly and weathered",
"Old": "sounding elderly and weathered",
}
VALID_AGES = {"Child", "Young", "Adult", "Middle-aged", "Elderly", "Unknown"}
def _score_map(traits: list[str], mapping: dict[str, str]) -> dict[str, int]:
scores: dict[str, int] = {}
for trait in traits:
trait_lower = trait.lower()
# Skip long compound descriptions that are situational, not vocal baseline
if len(trait_lower) > 30:
continue
for keyword, value in mapping.items():
if keyword in trait_lower:
scores[value] = scores.get(value, 0) + 1
return scores
NAME_INFERRED_PITCH: dict[tuple[str, ...], str] = {
("king", "queen", "admiral", "captain", "commander", "chief"): "low",
("princess", "prince", "child", "young"): "high",
("narrator", "announcer", "voice"): "mid",
}
NAME_INFERRED_ENERGY: dict[tuple[str, ...], str] = {
("king", "queen", "admiral", "captain", "commander", "chief", "boss", "leader"): "commanding",
("guard", "soldier", "officer", "security", "enforcer"): "assertive",
("servant", "maid", "butler", "nanny", "nurse", "assistant"): "soft_spoken",
("prince", "princess", "royal"): "assertive",
("fugitive", "prisoner", "captive"): "subdued",
("pirate", "raider", "rebel"): "volatile",
}
NAME_INFERRED_TIMBRE: dict[tuple[str, ...], str] = {
("king", "queen", "admiral", "captain"): "resonant",
("robot", "drone", "ai", "computer", "machine", "synthetic"): "thin",
("child", "kid", "young", "junior"): "clear",
("narrator", "announcer"): "resonant",
("guard", "soldier", "warrior"): "gravelly",
}
NAME_INFERRED_TEXTURE: dict[tuple[str, ...], str] = {
("robot", "drone", "ai", "computer", "machine", "synthetic"): "monotone",
("narrator", "announcer"): "smooth",
("professor", "teacher", "doctor", "scientist"): "clipped",
}
def _name_match(name_lower: str, keywords: tuple[str, ...]) -> bool:
return any(kw in name_lower for kw in keywords)
def _name_infer(name: str, mapping: dict[tuple[str, ...], str]) -> str | None:
name_lower = name.lower()
for keywords, value in mapping.items():
if _name_match(name_lower, keywords):
return value
return None
def _hash_vary(name: str, values: list[str]) -> str:
import hashlib
h = int(hashlib.md5(name.encode()).hexdigest()[:8], 16)
return values[h % len(values)]
def build_profile_from_speaker(speaker: dict[str, Any]) -> CharacterProfile:
name: str = speaker["primary_name"]
aliases: list[str] = speaker.get("aliases", [])
demographics: dict[str, str] = speaker.get("demographics", {})
vocal_traits: list[str] = speaker.get("vocal_traits", [])
comm_style: dict[str, str] = speaker.get("communication_style", {})
description: str = speaker.get("description", "")
is_narrator: bool = speaker.get("is_narrator", False)
accent: str = speaker.get("accent", "none")
gender: str = demographics.get("gender", "Unknown")
demo_type: str = demographics.get("type", "Human")
age: str = demographics.get("age", "Unknown")
explicit_pitch: str = demographics.get("pitch", "")
if is_narrator:
narrator_accent = (accent or "american").lower()
if narrator_accent == "none":
narrator_accent = "american"
accent_article = "an" if narrator_accent[0] in "aeiou" else "a"
return CharacterProfile(
name=name,
aliases=aliases,
gender="Male",
is_narrator=True,
pitch_register="very_low",
pace_baseline="slow",
energy="commanding",
timbre="resonant",
texture="smooth",
formality="formal",
emotional_baseline="neutral_calm",
distinguishing_trait="late-night radio host style",
accent_marker=narrator_accent,
voice_description=(
"A deep, resonant male voice with a calm, authoritative tone. "
"Rich baritone timbre, slow and deliberate pacing, "
f"spoken with {accent_article} {narrator_accent} accent, "
"like a late-night radio host. Very controlled and even."
),
)
demo_desc = DEMOGRAPHIC_TYPE_TO_DESC.get(demo_type, demo_type.lower())
register = comm_style.get("primary_register", "")
# Phase 1: Extract from vocal_traits (highest confidence)
pitch_scores = _score_map(vocal_traits, TRAIT_TO_PITCH)
energy_scores = _score_map(vocal_traits, TRAIT_TO_ENERGY)
timbre_scores = _score_map(vocal_traits, TRAIT_TO_TIMBRE)
texture_scores = _score_map(vocal_traits, TRAIT_TO_TEXTURE)
def best(scores: dict[str, int], default: str | None = None) -> str | None:
if not scores:
return None
max_val = max(scores.values())
candidates = [k for k, v in scores.items() if v == max_val]
if len(candidates) > 1 and default and default in candidates:
return default
return candidates[0]
pitch = best(pitch_scores, "mid")
energy = best(energy_scores, "measured")
timbre = best(timbre_scores, "clear")
texture = best(texture_scores, "smooth")
# Phase 2: Infer from demographics / robot-AI heuristics
if pitch is None:
if demo_type in ("Robot", "AI/Computer"):
pitch = _hash_vary(name, ["mid", "mid_low", "mid_high"])
elif gender:
pitch = _hash_vary(name, HASH_VARY_PITCH.get(gender, HASH_VARY_PITCH["Unknown"]))
else:
default_pitch = HASH_VARY_PITCH["Unknown"]
pitch = _name_infer(name, NAME_INFERRED_PITCH) or _hash_vary(name, default_pitch)
if energy is None:
if demo_type in ("Robot", "AI/Computer"):
energy = _hash_vary(name, ["measured", "assertive", "soft_spoken"])
else:
energy = _name_infer(name, NAME_INFERRED_ENERGY) or _hash_vary(name, HASH_VARY_ENERGY)
if timbre is None:
if demo_type in ("Robot", "AI/Computer"):
timbre = _hash_vary(name, ["metallic", "thin", "clear"])
elif gender:
timbre_options = HASH_VARY_TIMBRE.get(gender, HASH_VARY_TIMBRE["Unknown"])
timbre = _name_infer(name, NAME_INFERRED_TIMBRE) or _hash_vary(name, timbre_options)
else:
default_timbre = HASH_VARY_TIMBRE["Unknown"]
timbre = _name_infer(name, NAME_INFERRED_TIMBRE) or _hash_vary(name, default_timbre)
if texture is None:
if demo_type in ("Robot", "AI/Computer"):
texture = _hash_vary(name, ["monotone", "staccato", "smooth"])
else:
texture = _name_infer(name, NAME_INFERRED_TEXTURE) or _hash_vary(
name, HASH_VARY_TEXTURE
)
# User-supplied pitch and age overrides take precedence over inferred values
if explicit_pitch in VALID_PITCH_REGISTERS:
pitch = explicit_pitch
elif age in AGE_TO_PITCH_ADJUST and (age == "Child" or pitch == "mid"):
pitch = AGE_TO_PITCH_ADJUST[age]
if age == "Child" and timbre in ("gravelly", "metallic", "velvety"):
timbre = "clear"
# Phase 3: Formality and pace from communication_style
formality = REGISTER_TO_FORMALITY.get(register, "conversational")
pace = REGISTER_TO_PACE.get(register, "moderate")
# Override formality for robots
if demo_type in ("Robot", "AI/Computer"):
if register in ("Casual", "Conversational"):
formality = "conversational"
else:
formality = "formal"
# Phase 4: Inferred gender for description
male_keywords = ["king", "prince", "duke", "uncle", "captain", "admiral", "chief", "lord", "mr"]
female_keywords = ["princess", "queen", "duchess", "aunt", "lady", "madam", "ms", "mrs"]
inferred_gender = gender.lower() if gender.lower() in ("male", "female") else (
"male" if any(kw in name.lower() for kw in male_keywords) else
"female" if any(kw in name.lower() for kw in female_keywords) else
None
)
# Phase 5: Build rich voice description
desc_parts: list[str] = []
if inferred_gender == "male":
desc_parts.append("A male voice")
elif inferred_gender == "female":
desc_parts.append("A female voice")
else:
article = "An" if demo_desc[0] in "aeiou" else "A"
desc_parts.append(f"{article} {demo_desc} voice")
timbre_desc_map = {
"warm": "warm and rich",
"breathy": "soft and breathy",
"nasal": "slightly nasal",
"gravelly": "rough and gravelly",
"resonant": "deep and resonant",
"thin": "thin and reedy",
"clear": "clear and articulate",
"metallic": "metallic and synthetic",
"velvety": "smooth and velvety",
}
if timbre in timbre_desc_map:
desc_parts.append(f"with a {timbre_desc_map[timbre]} timbre")
if accent and accent != "none":
accent_strength = speaker.get("accent_strength", "medium")
accent_article = "an" if accent[0] in "aeiou" else "a"
strength_prefix = {
"light": f"{accent_article} subtle",
"strong": f"{accent_article} strong, broad",
}.get(accent_strength, accent_article)
desc_parts.append(f"with {strength_prefix} {accent} accent")
if age in AGE_DESCRIPTORS:
desc_parts.append(AGE_DESCRIPTORS[age])
if pitch != "mid":
pitch_desc = {
"very_low": "very low pitch",
"low": "low pitch",
"high": "high pitch",
"very_high": "very high pitch",
}.get(pitch, pitch)
desc_parts.append(pitch_desc)
if energy != "measured":
energy_desc = {
"whispered": "barely audible, hushed whisper",
"subdued": "speaking softly and calmly",
"soft_spoken": "speaking gently",
"assertive": "speaking with confidence",
"commanding": "speaking with authority",
"volatile": "speaking with intense emotion",
"explosive": "speaking with sudden, thunderous force",
}.get(energy, energy)
desc_parts.append(energy_desc)
if texture != "smooth":
texture_desc = {
"raspy": "with a raspy quality",
"clipped": "with clipped articulation",
"melodic": "with a melodic cadence",
"monotone": "in a monotone, uninflected manner",
"staccato": "in a staccato, choppy rhythm",
"lilting": "with a lilting, bouncy cadence",
}.get(texture, texture)
desc_parts.append(texture_desc)
if pace == "fast":
desc_parts.append("speaking rapidly")
elif pace == "slow":
desc_parts.append("speaking slowly and deliberately")
# Add distinguishing traits from vocal_traits that weren't matched
matched_keywords: set[str] = set()
for mapping in [TRAIT_TO_PITCH, TRAIT_TO_ENERGY, TRAIT_TO_TIMBRE, TRAIT_TO_TEXTURE]:
matched_keywords.update(mapping.keys())
unmatched = [t for t in vocal_traits if not any(kw in t.lower() for kw in matched_keywords)]
if unmatched:
desc_parts.append(f"Vocal characteristics: {', '.join(unmatched[:3])}.")
# Description always wins: the human/author-supplied description MUST shape the
# voice, even when vocal_traits contain unmatched keywords. (Previously gated on
# `not unmatched`, which silently dropped the description for any character with
# rich vocal_traits -- e.g. Nanny -- so editing the description produced no
# audible change and the user "couldn't get a different voice".)
if description:
desc_parts.append(description[:200].rstrip(".") + ".")
voice_description = " ".join(desc_parts) + "."
# Phase 6: Distinguishing trait
trait_parts: list[str] = []
if texture != "smooth":
trait_parts.append(f"{texture} texture")
if energy in ("volatile", "subdued"):
trait_parts.append(f"{energy} energy")
if formality != "conversational":
trait_parts.append(f"{formality} register")
if demo_type in ("Robot", "AI/Computer"):
trait_parts.append(f"{demo_desc} vocal quality")
if unmatched:
trait_parts.append(unmatched[0].lower())
distinguishing_trait = ", ".join(trait_parts) if trait_parts else "neutral delivery"
# Phase 7: Emotional baseline
emotion_keywords: dict[str, str] = {
"calm": "neutral_calm",
"angry": "angry_aggressive",
"fearful": "anxious_fearful",
"nervous": "anxious_fearful",
"desperate": "desperate_urgent",
"frantic": "desperate_urgent",
"sad": "sad_melancholic",
"weary": "weary_cynical",
"warm": "warm_kind",
"cheerful": "cheerful_energetic",
"authoritative": "authoritative_commanding",
"sassy": "sassy_confident",
"determined": "determined_resolute",
"cautious": "cautious_analytical",
"analytical": "cautious_analytical",
"polite": "warm_kind",
"stoic": "neutral_calm",
"monotone": "neutral_calm",
}
emotional_baseline = "neutral_calm"
for trait in vocal_traits:
for kw, val in emotion_keywords.items():
if kw in trait.lower():
emotional_baseline = val
break
if emotional_baseline != "neutral_calm":
break
if emotional_baseline == "neutral_calm" and energy == "volatile":
emotional_baseline = "intense_volatile"
elif emotional_baseline == "neutral_calm" and energy == "commanding":
emotional_baseline = "authoritative_commanding"
return CharacterProfile(
name=name,
aliases=aliases,
gender=gender if gender in ("Male", "Female") else None,
age_range=age if age in VALID_AGES and age != "Unknown" else None,
is_narrator=False,
personality=description[:200] if description else None,
voice_description=voice_description,
speech_patterns=comm_style.get("style_shifts_or_notes", "")[:500] or None,
pitch_register=pitch,
pace_baseline=pace,
energy=energy,
timbre=timbre,
texture=texture,
formality=formality,
emotional_baseline=emotional_baseline,
distinguishing_trait=distinguishing_trait,
accent_marker=accent if accent != "none" else "none",
accent_strength=speaker.get("accent_strength", "medium"),
)
ENHANCED_PROFILE_PROMPT = """You are an expert audiobook director and voice casting specialist. Analyze the following character
from a novel and create a detailed voice profile for text-to-speech generation.
CHARACTER DATA:
- Name: {name}
- Aliases: {aliases}
- Demographics: type={demo_type}, gender={gender}
- Vocal traits from text analysis: {vocal_traits}
- Communication style register: {comm_register}
- Communication style notes: {comm_notes}
- Description: {description}
- Sample dialogue: {sample_dialogue}
Already-cast profiles (avoid identical taxonomy tuples):
{existing_cast_summary}
INSTRUCTIONS:
Create a JSON profile with these exact fields. Use the vocal traits list heavily — it contains
specific adjectives extracted from the manuscript that describe how this character sounds.
{{
"personality": "Brief 1-2 sentence personality description derived from the character data.",
"voice_description": "Detailed 2-3 sentence voice description for TTS. Include gender, age feel, timbre, pitch, energy, and any accent evidence from the text.",
"emotion_tendencies": ["emotion1", "emotion2", "emotion3"],
"speech_patterns": "How this character speaks: sentence structure, register, rhythm, quirks.",
"narrator_style": "If they narrated, what style? (irrelevant for non-narrators, short answer ok)",
"pitch_register": "one of: very_low, low, mid_low, mid, mid_high, high, very_high, ultra_high",
"pace_baseline": "one of: very_slow, slow, moderate, brisk, fast, rapid",
"energy": "one of: whispered, subdued, soft_spoken, measured, assertive, commanding, volatile, explosive",
"timbre": "one of: warm, breathy, nasal, gravelly, clear, resonant, thin, metallic, velvety",
"texture": "one of: smooth, raspy, clipped, melodic, monotone, staccato, lilting",
"formality": "one of: casual, conversational, formal, archaic, military, academic",
"emotional_baseline": "short compound tag like weary_cynical or anxious_fearful or authoritative_commanding",
"distinguishing_trait": "one short phrase naming their most distinctive speech habit",
"accent_marker": "only set if text gives direct evidence; otherwise 'none'"
}}
CRITICAL RULES:
1. The vocal_traits list is the MOST IMPORTANT source — it contains direct textual evidence.
2. Ensure the taxonomy values are consistent with each other (e.g., "commanding" energy pairs with assertive traits, not subdued).
3. Robots and AI should tend toward monotone/staccato texture, metallic/thin timbre, measured energy.
4. Avoid duplicating taxonomy tuples of already-cast characters shown above.
5. Return ONLY valid JSON, no other text or markdown."""
def profiles_path(data_dir: Path) -> Path:
return data_dir / "voice_profiles.json"
def load_profiles(data_dir: Path) -> dict[str, dict[str, Any]]:
path = profiles_path(data_dir)
if path.exists():
with open(path) as f:
return json.load(f)
return {}
def save_profiles(data_dir: Path, profiles: dict[str, dict[str, Any]]) -> None:
path = profiles_path(data_dir)
with open(path, "w") as f:
json.dump(profiles, f, indent=2)
logger.info("Saved %d voice profiles to %s", len(profiles), path)
def profile_to_dict(profile: CharacterProfile) -> dict[str, Any]:
return profile.to_dict()
def dict_to_profile(data: dict[str, Any]) -> CharacterProfile:
return CharacterProfile.from_dict(data)
def generate_profiles_for_all_speakers(
speakers: list[dict[str, Any]],
data_dir: Path,
llm_config: Any = None,
) -> dict[str, CharacterProfile]:
existing = load_profiles(data_dir)
profiles: dict[str, CharacterProfile] = {}
for speaker in speakers:
name: str = speaker["primary_name"]
if name in existing:
profiles[name] = dict_to_profile(existing[name])
continue
profile = build_profile_from_speaker(speaker)
profiles[name] = profile
save_profiles(data_dir, {n: profile_to_dict(p) for n, p in profiles.items()})
return profiles
def get_profile_for_speaker(
name: str,
speaker: dict[str, Any],
data_dir: Path,
force_rebuild: bool = False,
) -> CharacterProfile:
existing = load_profiles(data_dir)
if name in existing and not force_rebuild:
return dict_to_profile(existing[name])
profile = build_profile_from_speaker(speaker)
existing[name] = profile_to_dict(profile)
save_profiles(data_dir, existing)
return profile