- 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
740 lines
26 KiB
Python
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
|