Files
UmbraAudio/umbraaudio/analysis/character_profiler.py
B.D. Murphy 0c9bea72e0 Add enterprise/infra tooling and repoint Qwen3-TTS submodule to internal fork
Two groups of changes, committed together as the follow-up to the initial push:

1. .gitmodules — submodule Qwen3-TTS now points at the internal Gitea fork
   (ssh://git@mini-eq:2222/umbragit/Qwen3-TTS.git) instead of upstream GitHub,
   so a clone actually gets the UmbraAudio branding/integration commit
   012e452 rather than vanilla upstream.

2. New infra/ tooling for the cross-machine work in spec 21: agent_bridge,
   remote_credential_broker (Keychain-backed, secrets read at runtime and
   generated via read_secret/random_secret — no hardcoded credentials), the
   enterprise bootstrap scripts, and the oldbox SSH/auth prompts. Plus
   backup/ restore scripts, two specs (18 UmbraTrans, 19 enterprise control
   plane), umbraaudio/voices/lmstudio_gate.py, and in-flight edits to the
   discovery/attribution/profiling modules, config, chapter_builder, emotion,
   pronunciation, qwen_tts and routes.

Also adds *.db to .gitignore: SQLite project databases are runtime state and no
.db file was tracked before.

Verified before staging: .env's only change is UMBRA_TTS__QWEN_TTS_HELPER_URL and
the committed copy has zero secret-looking values; the broker ships only
config.example.json.
2026-10-06 06:57:41 -07:00

304 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import json
import logging
from dataclasses import dataclass
from typing import Any
from umbraaudio.analysis import (
TAXONOMY_ENUMS,
CharacterProfile,
)
from umbraaudio.config import LLMConfig
from umbraaudio.voices.lmstudio_gate import queued_chat_completion
logger = logging.getLogger(__name__)
CHARACTER_PROFILE_PROMPT = """You are an expert audiobook director. Analyze the following character
from a novel and create a detailed voice profile for text-to-speech generation.
Character name: {name}
Known aliases: {aliases}
Gender (inferred): {gender}
Context from the book:
{context}
Already-cast voices in this book (avoid sounding like any of these):
{existing_cast_summary}
Create a JSON profile with these fields:
- personality: Brief personality description (1-2 sentences)
- voice_description: Detailed voice description for TTS voice design
(include gender, age, accent, tone, pitch characteristics)
- emotion_tendencies: List of 3-5 emotions this character commonly expresses
- speech_patterns: How this character speaks (formal/casual, sentence structure, etc.)
- narrator_style: If this character were narrating, what style would they use?
- pitch_register: one of [very_low, low, mid, high, very_high]
- pace_baseline: one of [very_slow, slow, moderate, brisk, fast]
- energy: one of [subdued, soft_spoken, measured, assertive, commanding, volatile]
- timbre: one of [warm, breathy, nasal, gravelly, clear, resonant, thin]
- texture: one of [smooth, raspy, clipped, melodious, monotone]
- formality: one of [casual, conversational, formal, archaic]
- emotional_baseline: short tag, e.g. "weary_cynical"
- distinguishing_trait: one short phrase describing a distinctive speech habit
- accent_marker: only set if the text gives direct evidence; otherwise "none"
Return ONLY valid JSON, no other text."""
EDIT_PROMPT_TEMPLATE = """You are adjusting a TTS voice profile based on director feedback.
Current profile:
{taxonomy_json}
Director's note: "{prompt}"
Return ONLY a JSON object containing the fields that should change, from:
pitch_register, pace_baseline, energy, timbre, texture, formality,
emotional_baseline, distinguishing_trait, voice_description.
Only include a field if the note actually justifies changing it.
Do not invent unrelated changes."""
@dataclass
class DistinctivenessResult:
is_distinct: bool
colliding_with: list[str]
matched_dimensions: list[str]
class CharacterProfiler:
def __init__(self, llm_config: LLMConfig) -> None:
self.config = llm_config
self._client = None
def _get_client(self) -> Any:
if self._client is not None:
return self._client
if self.config.provider in ("lmstudio", "auto"):
try:
import httpx
resp = httpx.get(f"{self.config.lmstudio_base_url}/models", timeout=5)
if resp.status_code == 200:
from openai import OpenAI
self._client = OpenAI(
base_url=self.config.lmstudio_base_url,
api_key="lm-studio",
)
return self._client
except Exception:
pass
if self.config.provider in ("openai", "auto"):
from openai import OpenAI
self._client = OpenAI()
return self._client
raise RuntimeError("No LLM provider available")
def _build_cast_summary(self, existing: list[CharacterProfile]) -> str:
if not existing:
return "(no other characters profiled yet)"
lines = []
for p in existing:
lines.append(
f"- {p.name}: pitch={p.pitch_register}, energy={p.energy}, "
f"timbre={p.timbre}, texture={p.texture}"
)
return "\n".join(lines)
async def profile_character(
self,
name: str,
aliases: list[str],
gender: str | None,
context: str,
existing_cast: list[CharacterProfile] | None = None,
retry_hint: DistinctivenessResult | None = None,
) -> CharacterProfile:
client = self._get_client()
cast_summary = self._build_cast_summary(existing_cast or [])
prompt = CHARACTER_PROFILE_PROMPT.format(
name=name,
aliases=", ".join(aliases) if aliases else "None",
gender=gender or "Unknown",
context=context[:2000],
existing_cast_summary=cast_summary,
)
if retry_hint and retry_hint.colliding_with:
collision_names = ", ".join(retry_hint.colliding_with)
dims = ", ".join(retry_hint.matched_dimensions)
prompt += (
f"\n\nIMPORTANT: Your previous profile collided with "
f"{collision_names} on dimensions: {dims}. "
f"Revise so this character differs in at least two of those "
f"dimensions while staying consistent with the text evidence."
)
response = queued_chat_completion(
client,
model=self.config.openai_model,
messages=[{"role": "user", "content": prompt}],
temperature=self.config.temperature,
max_tokens=self.config.max_tokens,
)
content = response.choices[0].message.content or "{}"
try:
data = json.loads(content)
except json.JSONDecodeError:
json_start = content.find("{")
json_end = content.rfind("}") + 1
if json_start >= 0 and json_end > json_start:
data = json.loads(content[json_start:json_end])
else:
logger.warning("Failed to parse LLM response for character %s", name)
data = {}
return CharacterProfile.from_dict(
{
"name": name,
"aliases": aliases,
"gender": gender,
**data,
}
)
async def check_distinctiveness(
self,
profile: CharacterProfile,
existing: list[CharacterProfile],
co_occurrence: dict[str, set[str]] | None = None,
) -> DistinctivenessResult:
new_tuple = profile.taxonomy_tuple()
colliding = []
matched = []
for other in existing:
if other.name == profile.name:
continue
if other.is_narrator:
continue
if co_occurrence and profile.name in co_occurrence:
if other.name not in co_occurrence[profile.name]:
continue
other_tuple = other.taxonomy_tuple()
dims = [
"pitch_register",
"pace_baseline",
"energy",
"timbre",
"texture",
"formality",
]
match_count = sum(1 for a, b in zip(new_tuple, other_tuple) if a == b)
if match_count >= 4:
match_dims = [dims[i] for i in range(6) if new_tuple[i] == other_tuple[i]]
colliding.append(other.name)
matched.extend(match_dims)
return DistinctivenessResult(
is_distinct=len(colliding) == 0,
colliding_with=colliding,
matched_dimensions=list(set(matched)),
)
async def apply_edit_prompt(
self,
profile: CharacterProfile,
prompt: str,
) -> CharacterProfile:
client = self._get_client()
taxonomy = {
"pitch_register": profile.pitch_register,
"pace_baseline": profile.pace_baseline,
"energy": profile.energy,
"timbre": profile.timbre,
"texture": profile.texture,
"formality": profile.formality,
"emotional_baseline": profile.emotional_baseline,
"distinguishing_trait": profile.distinguishing_trait,
"voice_description": profile.voice_description,
}
llm_prompt = EDIT_PROMPT_TEMPLATE.format(
taxonomy_json=json.dumps(taxonomy, indent=2),
prompt=prompt,
)
response = queued_chat_completion(
client,
model=self.config.openai_model,
messages=[{"role": "user", "content": llm_prompt}],
temperature=self.config.temperature,
max_tokens=self.config.max_tokens,
)
content = response.choices[0].message.content or "{}"
try:
deltas = json.loads(content)
except json.JSONDecodeError:
json_start = content.find("{")
json_end = content.rfind("}") + 1
if json_start >= 0 and json_end > json_start:
deltas = json.loads(content[json_start:json_end])
else:
logger.warning("Failed to parse edit prompt response")
return profile
new_data = profile.to_dict()
for key, value in deltas.items():
if key in TAXONOMY_ENUMS:
if value in TAXONOMY_ENUMS[key]:
new_data[key] = value
else:
logger.warning(
"Invalid taxonomy value for %s: %s, keeping %s",
key,
value,
getattr(profile, key),
)
elif key in ("emotional_baseline", "distinguishing_trait", "voice_description"):
new_data[key] = value
return CharacterProfile.from_dict(new_data)
async def generate_pronunciation_guide(self, terms: list[str], context: str) -> dict[str, str]:
client = self._get_client()
prompt = f"""Given these unusual words/names from a novel, provide IPA pronunciations.
Words: {", ".join(terms)}
Context snippet:
{context[:1000]}
Return a JSON object mapping each word to its IPA pronunciation.
Example: {{"Eilean": "eɪ.lən", "Fjord": "fjɔːrd"}}
Return ONLY valid JSON."""
response = queued_chat_completion(
client,
model=self.config.openai_model,
messages=[{"role": "user", "content": prompt}],
temperature=0.1,
max_tokens=2048,
)
content = response.choices[0].message.content or "{}"
try:
return json.loads(content)
except json.JSONDecodeError:
return {}