diff --git a/backend/main.py b/backend/main.py index 8b16f57..ace1736 100644 --- a/backend/main.py +++ b/backend/main.py @@ -32,7 +32,7 @@ from .services.stem_recorder import StemRecorder from .services.news import news_service, extract_keywords, STOP_WORDS from .services.regulars import regular_caller_service from .services.intern import intern_service -from .services.avatars import avatar_service +from .services.avatars import avatar_service, AVATAR_DIR app = FastAPI(title="AI Radio Show") @@ -245,7 +245,7 @@ async def _regenerate_backgrounds_for_keys(keys: list[str]): if not keys: return try: - await session._pregenerate_backgrounds() + session.caller_backgrounds = await session._build_backgrounds() print(f"[Background] Regenerated backgrounds after theme change (touched {len(keys)} unused slots)") except Exception as e: print(f"[Background] Regen failed: {e}") @@ -603,6 +603,8 @@ class Session: self.caller_queue: list[str] = [] # Sorted presentation order of caller keys self.intern_monitoring: bool = True # Devon monitors conversations by default self.show_theme: str = "" # Current show theme (e.g. "St. Patrick's Day") + self.pending_backgrounds: dict[str, dict] | None = None # Pre-warmed next-session lineup + self._prewarm_task: asyncio.Task | None = None def start_call(self, caller_key: str): self.current_caller_key = caller_key @@ -760,6 +762,17 @@ class Session: if self.current_caller_key: base = CALLER_BASES.get(self.current_caller_key) if base: + # Prefer the slim background's name/voice (actual caller identity) + # over the CALLER_BASES randomized defaults. The bg dict is the + # source of truth once pregen has populated it. + bg = self.caller_backgrounds.get(self.current_caller_key) + if isinstance(bg, dict) and bg.get("name") and bg.get("voice"): + return { + "name": bg["name"], + "voice": bg["voice"], + "vibe": self.get_caller_background(self.current_caller_key), + "tts_provider": base.get("tts_provider"), + } return { "name": base["name"], "voice": base["voice"], @@ -768,40 +781,126 @@ class Session: } return None - async def _pregenerate_backgrounds(self): - """Single sonnet-4.6 batch call generates all caller identities.""" + async def _build_backgrounds(self) -> dict[str, dict]: + """Generate a fresh caller lineup via two parallel sonnet-4.6 calls. + Returns a caller_backgrounds dict keyed by caller slot (no mutation).""" from .services import caller_gen, regulars_v2 from datetime import datetime voice_roster = [name for name in INWORLD_MALE_VOICES + INWORLD_FEMALE_VOICES if name not in BLACKLISTED_VOICES] + # Each active regular has an independent 50% chance to appear tonight. + REGULAR_APPEARANCE_PROB = 0.5 active_regulars = regulars_v2.load_all_active_regulars() regulars_for_tonight = [ - {"name": r.name, "lore": r.lore_body, "arc_state": r.arc_state} + {"name": r.name, "voice": r.voice, "age": r.age, + "lore": r.lore_body, "arc_state": r.arc_state} for r in active_regulars + if random.random() < REGULAR_APPEARANCE_PROB ][:3] + if regulars_for_tonight: + names = ", ".join(r["name"] for r in regulars_for_tonight) + print(f"[Background] Regulars tonight: {names}") + else: + print("[Background] No regulars tonight — all walk-ins") headlines: list[str] = [] if self.news_headlines: for h in self.news_headlines[:5]: headlines.append(h.title if hasattr(h, "title") else str(h)) - ctx = { + base_ctx = { "date": datetime.now().strftime("%A, %B %d, %Y"), "weather": "cool desert night", # TODO: real weather feed "headlines": headlines, "recent_caller_summaries": self._get_recent_summaries(), - "regulars_included": regulars_for_tonight, - "caller_count": 12, "voice_roster": voice_roster, } + # Split into two parallel calls: call 1 includes regulars + 5 walk-ins, + # call 2 is 6 pure walk-ins. Halves generation time by parallelizing. + ctx_a = {**base_ctx, "regulars_included": regulars_for_tonight, "caller_count": 6} + ctx_b = {**base_ctx, "regulars_included": [], "caller_count": 6} - identities = await caller_gen.generate_batch(ctx) + batches = await asyncio.gather( + caller_gen.generate_batch(ctx_a), + caller_gen.generate_batch(ctx_b), + return_exceptions=True, + ) + identities: list = [] + for i, result in enumerate(batches): + if isinstance(result, Exception): + print(f"[Background] Batch {i+1} failed: {result}") + continue + identities.extend(result) - for i, identity in enumerate(identities[:10]): - key = str(i + 1) - self.caller_backgrounds[key] = { + # Build regular lookup tables + regular_names = {r["name"] for r in regulars_for_tonight} + regular_voice_by_name = {r["name"]: r["voice"] for r in regulars_for_tonight} + regular_age_by_name = {r["name"]: r["age"] for r in regulars_for_tonight} + + # Separate regulars from walk-ins, filter leaks, dedupe walk-in names + regular_idents: dict[str, caller_gen.CallerIdentity] = {} + walk_ins: list[caller_gen.CallerIdentity] = [] + seen_walk_in_names: set[str] = set() + for ident in identities: + if ident.name in regular_names: + if ident.name in regular_idents: + continue + # Force-lock canonical voice/age (these voices are blacklisted + # from the sonnet roster, so voice_resolved would otherwise fall + # back to roster[0]). + ident.voice_resolved = regular_voice_by_name[ident.name] + ident.age = regular_age_by_name[ident.name] + regular_idents[ident.name] = ident + continue + # Drop walk-ins whose content references a regular by name (leak guard) + leaks_regular = False + for rname in regular_names: + if rname in ident.identity or rname in ident.situation: + leaks_regular = True + break + if leaks_regular: + print(f"[Background] Dropping caller '{ident.name}' — leaks regular into content") + continue + # Dedupe walk-ins by name (both parallel calls could produce same name) + if ident.name in seen_walk_in_names: + continue + seen_walk_in_names.add(ident.name) + walk_ins.append(ident) + + # If sonnet missed a gated-in regular, generate their situation directly + for r in regulars_for_tonight: + if r["name"] not in regular_idents: + print(f"[Background] Regular '{r['name']}' missing from batch output — generating directly") + try: + fields = await caller_gen.generate_regular_situation(r, ctx_a) + regular_idents[r["name"]] = caller_gen.CallerIdentity( + name=r["name"], + age=r["age"], + voice_suggestion=r["voice"], + location=fields.get("location", ""), + identity=fields.get("identity", ""), + situation=fields.get("situation", ""), + reason_calling=fields.get("reason_calling", ""), + opening_line=fields.get("opening_line", ""), + secret_want=fields.get("secret_want", ""), + specific_details=fields.get("specific_details", []), + emotional_register=fields.get("emotional_register", ""), + voice_resolved=r["voice"], + ) + except Exception as e: + print(f"[Background] Failed to generate fallback for '{r['name']}': {e}") + + # Regulars first (in the order they were gated in), then walk-ins + ordered = [regular_idents[r["name"]] for r in regulars_for_tonight if r["name"] in regular_idents] + ordered.extend(walk_ins) + + backgrounds: dict[str, dict] = {} + caller_keys = list(CALLER_BASES.keys()) # ["1"-"9","0"] + for i, identity in enumerate(ordered[:10]): + key = caller_keys[i] + backgrounds[key] = { "name": identity.name, "age": identity.age, "voice": identity.voice_resolved, @@ -814,7 +913,47 @@ class Session: "specific_details": identity.specific_details, "emotional_register": identity.emotional_register, } - print(f"[Background] Slim batch generated {len(identities[:10])} caller identities") + print(f"[Background] Built {len(backgrounds)} callers (from {len(identities)} raw, {len(regular_idents)} regulars locked)") + return backgrounds + + def start_prewarm(self): + """Kick off background generation of the NEXT session's lineup. + No-op if a pre-warm task is already running.""" + if self._prewarm_task and not self._prewarm_task.done(): + return + self._prewarm_task = asyncio.create_task(self._prewarm_next()) + + async def _prewarm_next(self): + """Generate next session's lineup and stash it in pending_backgrounds.""" + try: + print("[Background] Pre-warming next session's callers...") + self.pending_backgrounds = await self._build_backgrounds() + print("[Background] Next session's callers are pre-warmed and ready") + except Exception as e: + import traceback + print(f"[Background] Pre-warm failed: {e}") + print(traceback.format_exc()) + + async def populate_backgrounds(self): + """Install callers into this session. Uses pre-warmed batch if ready, + else generates fresh (blocking). Always kicks off next pre-warm.""" + if self.pending_backgrounds is not None: + self.caller_backgrounds = self.pending_backgrounds + self.pending_backgrounds = None + print(f"[Background] Swapped in pre-warmed backgrounds ({len(self.caller_backgrounds)} callers)") + else: + # Wait for in-flight pre-warm if present, otherwise generate fresh + if self._prewarm_task and not self._prewarm_task.done(): + print("[Background] Waiting for in-flight pre-warm to finish...") + await self._prewarm_task + if self.pending_backgrounds is not None: + self.caller_backgrounds = self.pending_backgrounds + self.pending_backgrounds = None + print(f"[Background] Swapped in pre-warmed backgrounds ({len(self.caller_backgrounds)} callers)") + self.start_prewarm() + return + self.caller_backgrounds = await self._build_backgrounds() + self.start_prewarm() def _get_recent_summaries(self) -> list[str]: # Return last 2 shows' caller summaries — stub for now, can wire into cost_db @@ -1225,7 +1364,11 @@ async def startup(): asyncio.create_task(_poll_imap_emails()) restored = _load_checkpoint() if not restored or not session.caller_backgrounds: - asyncio.create_task(session._pregenerate_backgrounds()) + # First reset will need callers — prewarm now so they're ready + session.start_prewarm() + else: + # Existing session good, but prewarm so next reset is instant + session.start_prewarm() asyncio.create_task(avatar_service.ensure_devon()) threading.Thread(target=_update_on_air_cdn, args=(False,), daemon=True).start() @@ -2089,13 +2232,15 @@ async def get_callers(): """Get list of available callers with background info for UI display""" callers = [] for k, v in CALLER_BASES.items(): + bg = session.caller_backgrounds.get(k) + # Prefer the slim bg name (actual caller identity) over the CALLER_BASES default + display_name = bg.get("name") if isinstance(bg, dict) and bg.get("name") else v["name"] caller_info = { "key": k, - "name": v["name"], + "name": display_name, "returning": v.get("returning", False), - "avatar_url": f"/api/avatar/{v['name']}", + "avatar_url": f"/api/avatar/{display_name}", } - bg = session.caller_backgrounds.get(k) if isinstance(bg, dict): details = bg.get("specific_details") or [] caller_info["identity"] = bg.get("identity", "") @@ -2119,11 +2264,11 @@ async def get_regulars(): @app.post("/api/session/reset") async def reset_session(): - """Reset session - all callers get fresh backgrounds""" + """Reset session - all callers get fresh backgrounds. + Uses pre-warmed batch if available (instant); otherwise generates fresh.""" session.reset() _chat_updates.clear() - # Pre-generate backgrounds in background so they're ready when callers are clicked - asyncio.create_task(session._pregenerate_backgrounds()) + await session.populate_backgrounds() return {"status": "reset", "session_id": session.id} @@ -4568,22 +4713,44 @@ async def _play_intern_audio(text: str): # --- Avatars --- +def _infer_gender_for_name(name: str) -> str: + """Infer gender for avatar lookup. Priority: + 1. Match against slim caller_backgrounds by name → use voice pool + 2. Match against CALLER_BASES by name → use slot gender + 3. Default to male + """ + # Priority 1: slim caller backgrounds (new redesigned system) + for bg in session.caller_backgrounds.values(): + if isinstance(bg, dict) and bg.get("name") == name: + voice = bg.get("voice", "") + if voice in INWORLD_FEMALE_VOICES: + return "female" + if voice in INWORLD_MALE_VOICES: + return "male" + break + # Priority 2: CALLER_BASES randomized names + for base in CALLER_BASES.values(): + if base.get("name") == name: + return base.get("gender", "male") + return "male" + + @app.get("/api/avatar/{name}") async def get_avatar(name: str): """Serve a caller's avatar image""" - path = avatar_service.get_path(name) - if path: + gender = _infer_gender_for_name(name) + # Check cache with gender-correctness; fetch/re-fetch if missing or wrong gender + path = AVATAR_DIR / f"{name}.jpg" + marker = AVATAR_DIR / f"{name}.gender" + if path.exists() and marker.exists() and marker.read_text().strip() == gender: return FileResponse(path, media_type="image/jpeg") - # Try to fetch on the fly — find gender from CALLER_BASES - gender = "male" - for base in CALLER_BASES.values(): - if base.get("name") == name: - gender = base.get("gender", "male") - break try: - path = await avatar_service.get_or_fetch(name, gender) - return FileResponse(path, media_type="image/jpeg") + fetched = await avatar_service.get_or_fetch(name, gender) + return FileResponse(fetched, media_type="image/jpeg") except Exception: + # If fetch fails but a stale file exists, serve it rather than 404 + if path.exists(): + return FileResponse(path, media_type="image/jpeg") raise HTTPException(404, "Avatar not found") diff --git a/backend/services/caller_gen.py b/backend/services/caller_gen.py index 718966d..aada1e4 100644 --- a/backend/services/caller_gen.py +++ b/backend/services/caller_gen.py @@ -92,6 +92,7 @@ def build_batch_prompt(ctx: dict) -> str: lines.append(f"Current arc state: {r['arc_state']}") lines.append("") lines.append(f"For {r['name']}: invent a fresh reason he is calling tonight — a new development, grievance, or specific recent event. DO NOT alter his voice, personality, or core traits. Write a new scene for an existing character.") + lines.append(f"CRITICAL — this caller MUST appear in the output JSON with EXACTLY these fields locked: name=\"{r['name']}\", voice_suggestion=\"{r['voice']}\", age={r['age']}. Do NOT rename this caller. Do NOT give him a different voice. Do NOT change his age. Only invent: location, identity, situation, reason_calling, opening_line, secret_want, specific_details, emotional_register.") lines.append("") lines.append(f"Available voices (voice_suggestion must match one of these exactly):") @@ -131,3 +132,54 @@ async def generate_batch(ctx: dict) -> list[CallerIdentity]: for c in callers: c.voice_resolved = resolve_voice(c.voice_suggestion, ctx["voice_roster"]) return callers + + +REGULAR_SYSTEM_PROMPT = """You are writing a single caller — a recurring character on Luke's late-night radio show. The character's identity is fixed (name, voice, age, core lore). You only invent a fresh reason he's calling tonight, grounded in his current arc state. + +Output strict JSON with these fields only: location, identity, situation, reason_calling, opening_line, secret_want, specific_details (array of 2-3 strings), emotional_register. Do NOT include name, voice_suggestion, or age — those are locked.""" + + +async def generate_regular_situation(regular: dict, ctx: dict) -> dict: + """Generate a fresh situation for a locked regular. Returns a dict matching + the CallerIdentity schema. One small LLM call (~$0.01).""" + lines = [ + f"Tonight is {ctx['date']}. {ctx['weather']}.", + "", + f"### {regular['name']}", + regular["lore"], + f"Current arc state: {regular['arc_state']}", + "", + f"Invent a fresh reason {regular['name']} is calling tonight — a new development, grievance, or specific recent event. DO NOT alter his voice, personality, or core traits.", + "", + "Output JSON only, no prose.", + ] + prompt = REGULAR_SYSTEM_PROMPT + "\n\n" + "\n".join(lines) + + async with httpx.AsyncClient(timeout=60.0) as client: + resp = await client.post( + "https://openrouter.ai/api/v1/chat/completions", + headers={"Authorization": f"Bearer {settings.openrouter_api_key}"}, + json={ + "model": BATCH_MODEL, + "messages": [{"role": "user", "content": prompt}], + "response_format": {"type": "json_object"}, + "max_tokens": 1500, + "temperature": 0.9, + }, + ) + resp.raise_for_status() + data = resp.json() + + content = data["choices"][0]["message"]["content"] + usage = data.get("usage", {}) + cost_tracker.record_llm_call( + category="background_gen", + model=BATCH_MODEL, + usage_data=usage, + ) + + stripped = content.strip() + if stripped.startswith("```") and stripped.endswith("```"): + lines = stripped.splitlines() + stripped = "\n".join(lines[1:-1]) + return json.loads(stripped) diff --git a/frontend/index.html b/frontend/index.html index b3345db..653fdae 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -324,6 +324,6 @@ - + diff --git a/frontend/js/app.js b/frontend/js/app.js index 6652248..c0051db 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -744,18 +744,32 @@ async function newSession() { await hangup(); } - await fetch('/api/session/reset', { method: 'POST' }); - conversationSince = 0; + const btn = document.getElementById('new-session-btn'); + const originalText = btn?.textContent; + if (btn) { + btn.disabled = true; + btn.textContent = 'Generating...'; + } - // Hide caller background - const bgDetails = document.getElementById('caller-background-details'); - if (bgDetails) bgDetails.classList.add('hidden'); + try { + await fetch('/api/session/reset', { method: 'POST' }); + conversationSince = 0; - // Reload callers to get new session ID - await loadCallers(); - await loadShowTheme(); + // Hide caller background + const bgDetails = document.getElementById('caller-background-details'); + if (bgDetails) bgDetails.classList.add('hidden'); - log('New session started - all callers have fresh backgrounds'); + // Reload callers to get new session ID + await loadCallers(); + await loadShowTheme(); + + log('New session started - all callers have fresh backgrounds'); + } finally { + if (btn) { + btn.disabled = false; + btn.textContent = originalText || 'New Session'; + } + } }