Merge branch 'feature/caller-redesign' into main
Brings in the Phase 5B caller-generation rewrite: - Slim two-stage pipeline: Sonnet 4.6 batch identity pregen at session start, Haiku 4.5 live dialog per turn. Deletes CallerBackground dataclass, shape/style/voice-matching, per-model routing, preflight UI. - New caller_gen.py (slim prompt builder) and regulars_v2.py (Obsidian lore loader for named recurring callers). - Reworked caller buttons and info panel around the slim background: identity, situation, signature, secret want. Removes shape badges, energy dots, emotion info-badges. - Inworld TTS: emotional_register -> (temperature, speed_adjust) mapping via _emotional_register_to_params(). applyTextNormalization ON. - Silas identity/voice leak fix, avatar gender, pre-warm batch gen. - Archives old regulars to data/regulars.archived.json, adds 10 sample caller transcripts under docs/samples/. Post-merge fixes applied during resolution: - intern.py: kept main's richer new_show() (NEW SHOW history marker, trim, _save()) and _track_suggestion(); removed the branch's duplicate minimal new_show() stub. - tts.py: added 5 voice speed overrides (Graham, Malcolm, Victoria, Loretta, Marlene) that main had tuned locally. Evelyn deliberately NOT added to VOICE_PROFILES — she is in BLACKLISTED_VOICES for unnatural prosody. - Main's cost dashboard polish, LLM per-model params, and Devon show context memory from commits c087c03..61b3cba carried through cleanly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+50
-14
@@ -128,6 +128,11 @@ INWORLD_SPEED_OVERRIDES = {
|
||||
"Theodore": 1.15,
|
||||
"Blake": 1.1,
|
||||
"Priya": 1.1,
|
||||
"Graham": 1.15,
|
||||
"Malcolm": 1.15,
|
||||
"Victoria": 1.1,
|
||||
"Loretta": 1.1,
|
||||
"Marlene": 1.1,
|
||||
}
|
||||
DEFAULT_INWORLD_SPEED = 1.1 # Slight bump for all voices
|
||||
|
||||
@@ -710,7 +715,33 @@ def _detect_speech_rate(text: str, base_speed: float) -> float:
|
||||
return base_speed
|
||||
|
||||
|
||||
async def generate_speech_inworld(text: str, voice_id: str) -> tuple[np.ndarray, int]:
|
||||
def _emotional_register_to_params(emotional_register: str) -> tuple[float, float]:
|
||||
"""Map caller's emotional_register description to (temperature, speed_adjust).
|
||||
speed_adjust is added to the base speed BEFORE clamping to 0.5-1.5.
|
||||
Returns (0.9, 0.0) default for unknown/empty registers."""
|
||||
if not emotional_register:
|
||||
return (0.9, 0.0)
|
||||
er = emotional_register.lower()
|
||||
# Sadness/grief family
|
||||
if any(kw in er for kw in ["fractured", "grief", "hollow", "sad", "mournful", "somber", "quietly desperate"]):
|
||||
return (0.85, -0.1)
|
||||
# Anger/aggression family
|
||||
if any(kw in er for kw in ["angry", "combative", "furious", "aggressive", "sharp intelligence"]):
|
||||
return (1.0, 0.1)
|
||||
# Manic/excited family
|
||||
if any(kw in er for kw in ["caffeinated", "manic", "giddy", "vibrating", "adrenaline", "frantic", "hyper-articulate"]):
|
||||
return (1.0, 0.15)
|
||||
# Nervous/earnest family
|
||||
if any(kw in er for kw in ["earnest", "nervous", "unsettled", "anxious", "tentative", "quietly unsettled"]):
|
||||
return (0.9, 0.0)
|
||||
# Gruff/restrained family
|
||||
if any(kw in er for kw in ["gruff", "measured", "precise", "stoic", "restrained", "deadpan", "forthright"]):
|
||||
return (0.85, -0.05)
|
||||
# Default
|
||||
return (0.9, 0.0)
|
||||
|
||||
|
||||
async def generate_speech_inworld(text: str, voice_id: str, emotional_register: str = "") -> tuple[np.ndarray, int]:
|
||||
"""Generate speech using Inworld TTS API (high quality, natural voices)"""
|
||||
import httpx
|
||||
import base64
|
||||
@@ -727,9 +758,10 @@ async def generate_speech_inworld(text: str, voice_id: str) -> tuple[np.ndarray,
|
||||
if not api_key:
|
||||
raise RuntimeError("INWORLD_API_KEY not set in environment")
|
||||
|
||||
base_speed = INWORLD_SPEED_OVERRIDES.get(voice, DEFAULT_INWORLD_SPEED)
|
||||
speed = _detect_speech_rate(text, base_speed)
|
||||
print(f"[Inworld TTS] Voice: {voice}, Speed: {speed:.2f} (base {base_speed}), Text: {text[:50]}...")
|
||||
temperature, speed_adjust = _emotional_register_to_params(emotional_register)
|
||||
base_speed = INWORLD_SPEED_OVERRIDES.get(voice, DEFAULT_INWORLD_SPEED) + speed_adjust
|
||||
speed = max(0.5, min(1.5, _detect_speech_rate(text, base_speed)))
|
||||
print(f"[Inworld TTS] Voice: {voice}, Speed: {speed:.2f} (base {base_speed:.2f}), Temp: {temperature}, Text: {text[:50]}...")
|
||||
|
||||
url = "https://api.inworld.ai/tts/v1/voice"
|
||||
headers = {
|
||||
@@ -740,6 +772,8 @@ async def generate_speech_inworld(text: str, voice_id: str) -> tuple[np.ndarray,
|
||||
"text": text,
|
||||
"voiceId": voice,
|
||||
"modelId": "inworld-tts-1.5-max",
|
||||
"temperature": temperature,
|
||||
"applyTextNormalization": "ON",
|
||||
"audioConfig": {
|
||||
"audioEncoding": "LINEAR16",
|
||||
"sampleRateHertz": 48000,
|
||||
@@ -797,14 +831,14 @@ def pick_caller_tts_provider() -> str | None:
|
||||
|
||||
|
||||
_TTS_PROVIDERS = {
|
||||
"kokoro": lambda text, vid: generate_speech_kokoro(text, vid),
|
||||
"f5tts": lambda text, vid: generate_speech_f5tts(text, vid),
|
||||
"inworld": lambda text, vid: generate_speech_inworld(text, vid),
|
||||
"chattts": lambda text, vid: generate_speech_chattts(text, vid),
|
||||
"styletts2": lambda text, vid: generate_speech_styletts2(text, vid),
|
||||
"bark": lambda text, vid: generate_speech_bark(text, vid),
|
||||
"vits": lambda text, vid: generate_speech_vits(text, vid),
|
||||
"elevenlabs": lambda text, vid: generate_speech_elevenlabs(text, vid),
|
||||
"kokoro": lambda text, vid, er: generate_speech_kokoro(text, vid),
|
||||
"f5tts": lambda text, vid, er: generate_speech_f5tts(text, vid),
|
||||
"inworld": lambda text, vid, er: generate_speech_inworld(text, vid, emotional_register=er),
|
||||
"chattts": lambda text, vid, er: generate_speech_chattts(text, vid),
|
||||
"styletts2": lambda text, vid, er: generate_speech_styletts2(text, vid),
|
||||
"bark": lambda text, vid, er: generate_speech_bark(text, vid),
|
||||
"vits": lambda text, vid, er: generate_speech_vits(text, vid),
|
||||
"elevenlabs": lambda text, vid, er: generate_speech_elevenlabs(text, vid),
|
||||
}
|
||||
|
||||
TTS_MAX_RETRIES = 2
|
||||
@@ -816,7 +850,8 @@ async def generate_speech(
|
||||
voice_id: str,
|
||||
phone_quality: str = "normal",
|
||||
apply_filter: bool = True,
|
||||
provider_override: str = None
|
||||
provider_override: str = None,
|
||||
emotional_register: str = "",
|
||||
) -> bytes:
|
||||
"""
|
||||
Generate speech from text with automatic retry on failure.
|
||||
@@ -827,6 +862,7 @@ async def generate_speech(
|
||||
phone_quality: Quality of phone filter ("none" to disable)
|
||||
apply_filter: Whether to apply phone filter
|
||||
provider_override: Override the global TTS provider for this call
|
||||
emotional_register: Caller's emotional register (used by Inworld for temp/speed tuning)
|
||||
|
||||
Returns:
|
||||
Raw PCM audio bytes (16-bit signed int, 24kHz)
|
||||
@@ -845,7 +881,7 @@ async def generate_speech(
|
||||
async with asyncio.timeout(20):
|
||||
for attempt in range(TTS_MAX_RETRIES):
|
||||
try:
|
||||
audio, sample_rate = await gen_fn(text, voice_id)
|
||||
audio, sample_rate = await gen_fn(text, voice_id, emotional_register)
|
||||
cost_tracker.record_tts_call(provider, voice_id, len(text))
|
||||
if attempt > 0:
|
||||
print(f"[TTS] Succeeded on retry {attempt}")
|
||||
|
||||
Reference in New Issue
Block a user