"""AI Radio Show - Control Panel Backend""" import os import uuid import asyncio import base64 import subprocess import threading import traceback from dataclasses import dataclass, field, asdict from pathlib import Path from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect, Request, Response from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse, JSONResponse from backend.services import cost_db import json import time import httpx import numpy as np from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel from typing import Optional from .config import settings from .services.caller_service import CallerService from .services.transcription import transcribe_audio from .services.llm import llm_service from .services.cost_tracker import cost_tracker, LLMCallRecord, TTSCallRecord from .services.tts import generate_speech from .services.audio import audio_service 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, AVATAR_DIR app = FastAPI(title="AI Radio Show") app.add_middleware( CORSMiddleware, allow_origins=[ "http://localhost:8000", "http://localhost:3000", "http://127.0.0.1:8000", "http://127.0.0.1:3000", ], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # --- Callers --- # Base caller info (name, voice) - backgrounds generated dynamically per session import random MALE_NAMES = [ "Tony", "Rick", "Dennis", "Earl", "Marcus", "Keith", "Darnell", "Wayne", "Greg", "Andre", "Ray", "Jerome", "Hector", "Travis", "Vince", "Leon", "Dale", "Frank", "Terrence", "Bobby", "Cliff", "Nate", "Reggie", "Carl", "Donnie", "Mitch", "Lamar", "Tyrone", "Russell", "Cedric", "Marvin", "Curtis", "Rodney", "Clarence", "Floyd", "Otis", "Chester", "Leroy", "Melvin", "Vernon", "Dwight", "Benny", "Elvin", "Alonzo", "Dexter", "Roland", "Wendell", "Clyde", "Luther", "Virgil", "Ernie", "Lenny", "Sal", "Gus", "Moe", "Archie", "Duke", "Sonny", "Red", "Butch", "Skeeter", "T-Bone", "Slim", "Big Mike", "Chip", "Ricky", "Darryl", "Pete", "Artie", "Stu", "Phil", "Murray", "Norm", "Woody", "Rocco", "Paulie", "Vinnie", "Frankie", "Mikey", "Joey", ] FEMALE_NAMES = [ "Jasmine", "Megan", "Tanya", "Carla", "Brenda", "Sheila", "Denise", "Tamika", "Lorraine", "Crystal", "Angie", "Renee", "Monique", "Gina", "Patrice", "Deb", "Shonda", "Marlene", "Yolanda", "Stacy", "Jackie", "Carmen", "Rita", "Val", "Diane", "Connie", "Wanda", "Doris", "Maxine", "Gladys", "Pearl", "Lucille", "Rochelle", "Bernadette", "Thelma", "Dolores", "Naomi", "Bonnie", "Francine", "Irene", "Estelle", "Charlene", "Yvonne", "Roberta", "Darlene", "Adrienne", "Vivian", "Rosalie", "Pam", "Barb", "Cheryl", "Jolene", "Mavis", "Faye", "Luann", "Peggy", "Dot", "Bev", "Tina", "Lori", "Sandy", "Debbie", "Terri", "Cindy", "Tonya", "Keisha", "Latoya", "Shaniqua", "Aaliyah", "Ebony", "Lakisha", "Shanice", "Nikki", "Candy", "Misty", "Brandy", "Tiffany", "Amber", "Heather", "Jen", ] # Voice pools per TTS provider INWORLD_MALE_VOICES = [ "Alex", "Arjun", "Arthur", "Avery", "Blake", "Brandon", "Brian", "Callum", "Carter", "Cedric", "Clive", "Conrad", "Craig", "Damon", "Daniel", "Dennis", "Derek", "Duncan", "Edward", "Elliot", "Ethan", "Evan", "Felix", "Gareth", "Graham", "Grant", "Hades", "Hamish", "Hank", "Jake", "James", "Jason", "Jonah", "Levi", "Liam", "Malcolm", "Marcus", "Mark", "Mortimer", "Nate", "Oliver", "Reed", "Ronald", "Rupert", "Sebastian", "Shaun", "Simon", "Theodore", "Timothy", "Trevor", "Tristan", "Tyler", "Victor", "Vinny", ] INWORLD_FEMALE_VOICES = [ "Amina", "Anjali", "Ashley", "Bianca", "Brooke", "Celeste", "Chloe", "Claire", "Darlene", "Deborah", "Eleanor", "Elizabeth", "Evelyn", "Hana", "Jessica", "Joy", "Julia", "Kayla", "Kelsey", "Lauren", "Loretta", "Luna", "Marlene", "Miranda", "Nadia", "Naomi", "Olivia", "Pippa", "Priya", "Saanvi", "Sarah", "Selene", "Serena", "Sophie", "Tessa", "Veronica", "Victoria", "Wendy", "Zadie", ] ELEVENLABS_MALE_VOICES = [ "CwhRBWXzGAHq8TQ4Fs17", # Roger - Laid-Back, Casual "IKne3meq5aSn9XLyUdCD", # Charlie - Deep, Confident "JBFqnCBsd6RMkjVDRZzb", # George - Warm Storyteller "N2lVS1w4EtoT3dr4eOWO", # Callum - Husky Trickster "SOYHLrjzK2X1ezoPC6cr", # Harry - Fierce "TX3LPaxmHKxFdv7VOQHJ", # Liam - Energetic "bIHbv24MWmeRgasZH58o", # Will - Relaxed Optimist "cjVigY5qzO86Huf0OWal", # Eric - Smooth, Trustworthy "iP95p4xoKVk53GoZ742B", # Chris - Charming "nPczCjzI2devNBz1zQrb", # Brian - Deep, Resonant "onwK4e9ZLuTAKqWW03F9", # Daniel - Steady Broadcaster "pNInz6obpgDQGcFmaJgB", # Adam - Dominant, Firm "pqHfZKP75CvOlQylNhV4", # Bill - Wise, Mature ] ELEVENLABS_FEMALE_VOICES = [ "EXAVITQu4vr4xnSDxMaL", # Sarah - Mature, Reassuring "FGY2WhTYpPnrIDTdsKH5", # Laura - Enthusiast, Quirky "Xb7hH8MSUJpSbSDYk0k2", # Alice - Clear Educator "XrExE9yKIg1WjnnlVkGX", # Matilda - Professional "cgSgspJ2msm6clMCkdW9", # Jessica - Playful, Bright "hpp4J3VqNfWAUOO0d1Us", # Bella - Professional, Warm "pFZP5JQG7iQjIQuC4Bku", # Lily - Velvety Actress ] # River is gender-neutral, add to both pools ELEVENLABS_MALE_VOICES.append("SAz9YHcvj6GT2YYXdXww") # River - Neutral ELEVENLABS_FEMALE_VOICES.append("SAz9YHcvj6GT2YYXdXww") # River - Neutral # Voices to never assign to callers (bad quality, reserved for named characters, etc.) BLACKLISTED_VOICES = { "Evelyn", "Celeste", "Lauren", # unnatural prosody "Hades", # fantasy-style voice, too theatrical for callers "Sebastian", # reserved for Silas & Chip (regulars) "Nate", # reserved for Devon (intern) "Miranda", # reserved for Shonda (regular) "Hana", # reserved for Aaliyah (regular) "Mortimer", # reserved for Ernie (regular) "Ashley", # reserved for Monique (regular) "Deborah", # reserved for Rosalie (regular) } def _get_voice_pools(): """Get male/female voice pools based on active TTS provider.""" provider = settings.tts_provider if provider == "elevenlabs": return ELEVENLABS_MALE_VOICES, ELEVENLABS_FEMALE_VOICES # Default to Inworld voices (also used as fallback for other providers) males = [v for v in INWORLD_MALE_VOICES if v not in BLACKLISTED_VOICES] females = [v for v in INWORLD_FEMALE_VOICES if v not in BLACKLISTED_VOICES] return males, females CALLER_BASES = { "1": {"gender": "male", "age_range": (28, 62)}, "2": {"gender": "female", "age_range": (22, 55)}, "3": {"gender": "male", "age_range": (30, 65)}, "4": {"gender": "female", "age_range": (21, 45)}, "5": {"gender": "male", "age_range": (25, 58)}, "6": {"gender": "female", "age_range": (28, 52)}, "7": {"gender": "male", "age_range": (40, 72)}, "8": {"gender": "female", "age_range": (30, 60)}, "9": {"gender": "male", "age_range": (21, 38)}, "0": {"gender": "female", "age_range": (35, 65)}, } # Safety: enforce all callers are 18+ for _cb in CALLER_BASES.values(): lo, hi = _cb["age_range"] _cb["age_range"] = (max(18, lo), max(18, hi)) def _randomize_callers(): """Assign random names and voices to callers, unique per gender. Overrides 2-3 slots with returning regulars when available.""" num_m = sum(1 for c in CALLER_BASES.values() if c["gender"] == "male") num_f = sum(1 for c in CALLER_BASES.values() if c["gender"] == "female") # Get returning callers first so we can exclude their names from random pool returning = [] try: # Only inject returning callers if pool is large enough for variety eligible = regular_caller_service.get_returning_callers(10) # get all eligible inject_count = min(2, max(0, len(eligible) - 1)) # need 3+ eligible to inject 2, 2+ for 1 returning = eligible[:inject_count] except Exception as e: print(f"[Regulars] Failed to get returning callers: {e}") returning_names = {r["name"] for r in returning} avail_males = [n for n in MALE_NAMES if n not in returning_names] avail_females = [n for n in FEMALE_NAMES if n not in returning_names] males = random.sample(avail_males, num_m) females = random.sample(avail_females, num_f) male_pool, female_pool = _get_voice_pools() m_voices = random.sample(male_pool, min(num_m, len(male_pool))) f_voices = random.sample(female_pool, min(num_f, len(female_pool))) mi, fi = 0, 0 from .services.tts import pick_caller_tts_provider for base in CALLER_BASES.values(): base["returning"] = False base["regular_id"] = None base["tts_provider"] = pick_caller_tts_provider() if base["gender"] == "male": base["name"] = males[mi] base["voice"] = m_voices[mi] mi += 1 else: base["name"] = females[fi] base["voice"] = f_voices[fi] fi += 1 # Override 2-3 random slots with returning callers try: if returning: keys_by_gender = {"male": [], "female": []} for k, v in CALLER_BASES.items(): keys_by_gender[v["gender"]].append(k) for regular in returning: gender = regular["gender"] candidates = keys_by_gender.get(gender, []) if not candidates: continue key = random.choice(candidates) candidates.remove(key) base = CALLER_BASES[key] base["name"] = regular["name"] base["returning"] = True base["regular_id"] = regular["id"] # Restore their stored voice so they sound the same every time if regular.get("voice"): base["voice"] = regular["voice"] if returning: names = [r["name"] for r in returning] print(f"[Regulars] Injected returning callers: {', '.join(names)}") except Exception as e: print(f"[Regulars] Failed to inject returning callers: {e}") _randomize_callers() # Initial assignment async def _regenerate_backgrounds_for_keys(keys: list[str]): """Regenerate backgrounds for the given slot keys only (e.g. unused callers after a theme change). Runs a fresh batch and copies only the requested slots into session.caller_backgrounds — used slots are preserved so in-progress/completed calls keep their identities.""" if not keys: return try: fresh = await session._build_backgrounds() for k in keys: if k in fresh: session.caller_backgrounds[k] = fresh[k] print(f"[Background] Regenerated backgrounds for {len(keys)} slots: {', '.join(keys)}") except Exception as e: print(f"[Background] Regen failed: {e}") # Known topics for smarter search queries — maps keywords in backgrounds to search terms _TOPIC_SEARCH_MAP = [ # TV shows (["severance"], "Severance TV show"), (["landman"], "Landman TV show"), (["fallout"], "Fallout TV show"), (["breaking bad"], "Breaking Bad"), (["wire"], "The Wire HBO"), (["game of thrones", "thrones"], "Game of Thrones"), (["westworld"], "Westworld"), (["yellowstone"], "Yellowstone TV show"), (["lost"], "LOST TV show"), (["stranger things"], "Stranger Things"), (["better call saul"], "Better Call Saul"), (["mad men"], "Mad Men"), (["sopranos"], "The Sopranos"), (["true detective"], "True Detective"), (["slow horses"], "Slow Horses"), (["silo"], "Silo TV show"), (["last of us"], "The Last of Us TV show"), (["poker face"], "Poker Face TV show"), (["shogun"], "Shogun TV show"), # Science & space (["exoplanet", "jwst", "james webb"], "James Webb Space Telescope discovery"), (["quantum", "entanglement", "double-slit"], "quantum physics research"), (["fusion energy", "fusion"], "fusion energy research"), (["cern", "particle physics"], "CERN physics"), (["mars mission", "mars"], "Mars exploration NASA"), (["neuroscience", "consciousness"], "neuroscience consciousness research"), (["dark matter", "dark energy"], "dark matter dark energy research"), (["gravitational waves"], "gravitational waves discovery"), (["extraterrestrial", "alien life"], "search for extraterrestrial life"), (["battery technology"], "battery technology breakthrough"), # Technology (["spacex"], "SpaceX launch"), (["cybersecurity", "breach"], "cybersecurity news"), (["ai ", "artificial intelligence"], "AI artificial intelligence news"), (["open source"], "open source software news"), (["energy grid"], "energy grid infrastructure"), # Poker (["poker"], "poker tournament"), # Photography (["astrophotography", "milky way"], "astrophotography"), (["dark skies"], "dark sky photography"), # Physics & big questions (["multiverse"], "multiverse theory physics"), (["black hole"], "black hole discovery"), (["simulation theory"], "simulation theory"), (["free will", "determinism"], "free will physics"), (["nature of time"], "physics time"), # US News (["water rights"], "southwest water rights"), (["broadband", "rural"], "rural broadband"), (["infrastructure"], "infrastructure project"), (["economy"], "US economy"), ] def _extract_search_query(background: str) -> str | None: """Extract a smart search query from a caller's background. Checks for known topics first, falls back to keyword extraction.""" bg_lower = background.lower() # Check known topics first for keywords, query in _TOPIC_SEARCH_MAP: for kw in keywords: if kw in bg_lower: return query # Fallback: pull meaningful words from the second sentence (the problem/topic) sentences = background.split(".") topic_text = sentences[1].strip() if len(sentences) > 1 else "" if not topic_text: return None search_words = [w.lower() for w in topic_text.split() if len(w) > 4 and w.lower() not in STOP_WORDS][:3] if not search_words: return None return " ".join(search_words) # Big Bend region towns the show lives in, with coordinates for weather lookups. # Longer names first so "fort stockton" matches before "stockton"-style partials. BIG_BEND_TOWNS: dict[str, tuple[float, float]] = { "fort stockton": (30.8949, -102.8794), "fort davis": (30.5882, -103.8946), "big bend": (29.2700, -103.3000), # Chisos Basin "alpine": (30.3585, -103.6610), "marfa": (30.3098, -104.0207), "marathon": (30.2074, -103.2452), "terlingua": (29.3216, -103.6168), "presidio": (29.5602, -104.3707), } # WMO weather codes (Open-Meteo) → short human phrase _WMO_PHRASE = { 0: "clear skies", 1: "mostly clear", 2: "partly cloudy", 3: "overcast", 45: "foggy", 48: "freezing fog", 51: "light drizzle", 53: "drizzle", 55: "heavy drizzle", 61: "light rain", 63: "rain", 65: "heavy rain", 71: "light snow", 73: "snow", 75: "heavy snow", 80: "rain showers", 81: "rain showers", 82: "heavy rain showers", 95: "thunderstorms", 96: "thunderstorms", 99: "thunderstorms", } def _get_town_from_location(text: str) -> Optional[str]: """Return the canonical Big Bend town key mentioned in a location string, or None. Matches 'ft stockton' as 'fort stockton'.""" if not text: return None low = " " + text.lower().replace("ft.", "fort").replace("ft ", "fort ") + " " for town in BIG_BEND_TOWNS: if town in low: return town return None async def _get_weather_for_town(town: str) -> Optional[str]: """Current conditions for a Big Bend town via Open-Meteo (free, no key). Returns e.g. '58°F, clear skies', or None on failure/unknown town.""" coords = BIG_BEND_TOWNS.get(town) if not coords: return None lat, lon = coords async with httpx.AsyncClient(timeout=5.0) as client: resp = await client.get( "https://api.open-meteo.com/v1/forecast", params={ "latitude": lat, "longitude": lon, "current": "temperature_2m,weather_code", "temperature_unit": "fahrenheit", }, ) resp.raise_for_status() cur = resp.json().get("current", {}) temp = cur.get("temperature_2m") if temp is None: return None phrase = _WMO_PHRASE.get(cur.get("weather_code"), "") return f"{round(temp)}°F, {phrase}" if phrase else f"{round(temp)}°F" async def enrich_caller_background(background: str) -> str: """Search for a relevant article and local town news, summarize naturally. Called once at pickup time — never during live conversation.""" # Topic/interest enrichment — only ~40% of callers have read something relevant try: query = _extract_search_query(background) if query and random.random() < 0.4: async with asyncio.timeout(5): results = await news_service.search_topic(query) if results: article = results[0] raw_info = f"Headline: {article.title}" if article.content: raw_info += f"\nSnippet: {article.content[:200]}" summary = await llm_service.generate( messages=[{"role": "user", "content": raw_info}], system_prompt="Summarize this article in one casual sentence, as if someone is describing what they read. Start with 'Recently read about' or 'Saw an article about'. Keep it under 20 words. No quotes.", category="news_summary", ) summary = summary.strip().rstrip('.') if summary and len(summary) < 150: background += f" {summary}, and it's been on their mind." print(f"[Research] Topic enrichment ({query}): {summary[:60]}...") except TimeoutError: pass except Exception as e: print(f"[Research] Topic enrichment failed: {e}") # Weather enrichment try: town = _get_town_from_location(background.split(".")[0]) if town: async with asyncio.timeout(3): weather = await _get_weather_for_town(town) if weather: background += f" Weather right now: {weather}." print(f"[Research] Weather for {town}: {weather}") except TimeoutError: pass except Exception as e: print(f"[Research] Weather lookup failed: {e}") # Local town news enrichment try: if not town: town = _get_town_from_location(background.split(".")[0]) if town and town != "big bend": # the park, not a town with local news async with asyncio.timeout(4): town_query = f"{town.title()} Texas" results = await news_service.search_topic(town_query) if results: article = results[0] raw_info = f"Headline: {article.title}" if article.content: raw_info += f"\nSnippet: {article.content[:200]}" summary = await llm_service.generate( messages=[{"role": "user", "content": raw_info}], system_prompt="Summarize this local news in one casual sentence, as if someone from this town is describing what's going on. Start with 'Been hearing about' or 'Saw that'. Keep it under 20 words. No quotes.", category="news_summary", ) summary = summary.strip().rstrip('.') if summary and len(summary) < 150: background += f" {summary}." print(f"[Research] Town enrichment ({town_query}): {summary[:60]}...") except TimeoutError: pass except Exception as e: print(f"[Research] Town enrichment failed: {e}") return background def detect_host_mood(messages: list[dict], wrapping_up: bool = False) -> str: """Analyze recent host messages to detect mood signals for caller adaptation.""" if wrapping_up: return "\nEMOTIONAL READ ON THE HOST:\n- The host is DONE with this call. Give a SHORT goodbye — one sentence max. Do not introduce new topics.\n" host_msgs = [m["content"] for m in messages if m.get("role") in ("user", "host")][-5:] if not host_msgs: return "" signals = [] # Check average word count — short responses suggest dismissiveness avg_words = sum(len(m.split()) for m in host_msgs) / len(host_msgs) if avg_words < 8: signals.append("The host is giving short responses — they might be losing interest, testing you, or waiting for you to bring something real. Don't ramble. Get to the point or change the subject.") # Pushback patterns pushback_phrases = ["i don't think", "that's not", "come on", "really?", "i disagree", "that doesn't", "are you sure", "i don't buy", "no way", "but that's", "hold on", "wait a minute", "let's be honest"] pushback_count = sum(1 for m in host_msgs for p in pushback_phrases if p in m.lower()) if pushback_count >= 2: signals.append("The host is pushing back — they're challenging you. Don't fold immediately. Defend your position or concede specifically, not generically.") # Supportive patterns supportive_phrases = ["i hear you", "that makes sense", "i get it", "that's real", "i feel you", "you're right", "absolutely", "exactly", "good for you", "i respect that", "that took guts", "i'm glad you"] supportive_count = sum(1 for m in host_msgs for p in supportive_phrases if p in m.lower()) if supportive_count >= 2: signals.append("The host is being supportive — they're with you. You can go deeper. Share something you've been holding back.") # Joking patterns joke_indicators = ["haha", "lmao", "lol", "that's hilarious", "no way", "you're killing me", "shut up", "get out", "are you serious", "you're joking"] joke_count = sum(1 for m in host_msgs for p in joke_indicators if p in m.lower()) if joke_count >= 2: signals.append("The host is in a playful mood — joking around. You can joke back, lean into the humor, but you can also use it as a door to something real.") # Probing — lots of questions question_count = sum(m.count("?") for m in host_msgs) if question_count >= 3: signals.append("The host is asking a lot of questions — they're digging. Give them real answers. Don't deflect.") # Wrapping up — host is trying to end the call wrapup_phrases = ["thanks for calling", "appreciate you calling", "good luck with", "take care", "let us know how it goes", "keep us posted", "we gotta move on", "i gotta", "let's move on", "next caller", "we're running", "good talking to you", "hang in there", "best of luck", "you'll figure it out", "i think you know what to do", "glad you called", "we'll be right back", "alright well", "alright man", "alright brother", "you got this"] last_msg = host_msgs[-1].lower() if host_msgs else "" if any(p in last_msg for p in wrapup_phrases): signals.append("The host is wrapping up the call. Do NOT try to keep them on the line. Say a brief, natural goodbye — 'thanks Luke,' 'appreciate it,' 'alright, take care' — and let it end. One sentence max. Do not introduce new topics or ask more questions.") if not signals: return "" # Cap at 2 signals signals = signals[:2] return "\nEMOTIONAL READ ON THE HOST:\n" + "\n".join(f"- {s}" for s in signals) + "\n" def get_caller_prompt(caller: dict, theme: str = "") -> str: """Caller system prompt. Identity carries the weight.""" name = caller.get("name", "") identity = caller.get("identity", "") situation = caller.get("situation", "") reason = caller.get("reason_calling", "") want = caller.get("secret_want", "") opening = caller.get("opening_line", "") details = caller.get("specific_details", []) or [] detail_str = " | ".join(f"- {d}" for d in details) opening_block = "" if opening: opening_block = f""" Your planned opening line (use this or something very close for your FIRST message — do NOT say you've been listening for years, do NOT use generic radio caller clichés): "{opening}" """ # Returning-caller memory block. Memory fields are usually attached to the # slim dict at generation time, but slim dicts restored from checkpoint or # preserved across a partial-regenerate (theme change) won't have them — so # fall back to a name lookup against the regulars store. is_regular = caller.get("is_regular", False) arc_state = (caller.get("arc_state") or "").strip() prior = [s for s in (caller.get("prior_summaries") or []) if s] if not is_regular and name: persisted = regular_caller_service.get_by_name(name) if persisted: is_regular = True if not prior: prior = [ (e.get("summary") or "").strip()[:400] for e in (persisted.get("call_history") or [])[-4:] if (e.get("summary") or "").strip() ] if not arc_state: try: from .services import regulars_v2 for r in regulars_v2.load_all_active_regulars(): if r.name.lower() == name.lower(): is_regular = True arc_state = (r.arc_state or "").strip() break except Exception: pass regular_block = "" if is_regular: regular_block = "\n\nYOU ARE A RECURRING CALLER ON THIS SHOW. Luke knows you. You've called before — do NOT introduce yourself as a first-time listener.\n" if prior: regular_block += "\nYour past calls with Luke (oldest first — these things actually happened, reference them naturally when relevant):\n" for i, s in enumerate(prior, 1): regular_block += f"{i}. {s.strip()}\n" if arc_state: regular_block += f"\nWhere your story stands going into tonight: {arc_state}\n" if prior or arc_state: regular_block += "\nIf Luke asks about something from a past call, you remember it. You and he have a rapport built from those prior conversations.\n" theme_block = "" if theme: theme_block = f'\n\nTonight\'s show theme: "{theme}". If your reason for calling connects to this, lean into the connection with energy and specificity. If it genuinely doesn\'t fit your story, don\'t force it.\n' return f"""You are {name}. {identity} You're calling Luke's late-night radio show because: {situation} — specifically, {reason}. What you secretly want from this call: {want} {opening_block} Specific details you'll drop if it feels natural: {detail_str} {regular_block}{theme_block} Speak as this person. React to what Luke says. Stay in character. CRITICAL OUTPUT RULES: - Output ONLY the words the caller says out loud. - NEVER use asterisks. No *pause*, *breath*, *sighs*, *voice gets quieter* — none of it. - NEVER use parenthetical stage directions. No (laughs), (nervous), (sighs). - No narration. No describing what you're doing or feeling except through what you say. - If you catch yourself writing an asterisk or parenthesis, delete it and just say the words instead. - NEVER say you've "been listening for X years" or "long-time listener, first-time caller." Just get into your story. - If you've already shared a specific fact or detail in this conversation, do NOT restate it. Move the story forward — share something new, react to what Luke just said, or escalate the stakes. YOU CAN BE MOVED — DON'T CIRCLE: - You have a position and something you want, but you're a real person, not a stuck record. If Luke makes a good point or gives real advice, actually take it in — let it land. Agree, change your mind, soften, get defensive, dig in harder, or come to a decision. Something shifts. - NEVER restate the same dilemma, worry, or argument you've already made. If you catch yourself going in a circle, that's the moment to move: make a decision, admit something new, react to his point, or let the conversation turn somewhere it hasn't been. - It's good to reach a resolution, a choice, or an emotional turn over the call. You are NOT required to stay stuck in the same problem until you're cut off. Real conversations go somewhere. Mix short punchy replies with longer ones where natural. Real callers breathe, react in fragments, ask their own questions — they don't deliver a monologue every turn.""" # --- Session State --- @dataclass class CallRecord: caller_type: str # "ai" or "real" caller_name: str # "Tony" or "Caller #3" summary: str # LLM-generated summary after hangup transcript: list[dict] = field(default_factory=list) started_at: float = 0.0 ended_at: float = 0.0 quality_signals: dict = field(default_factory=dict) # Per-call quality heuristics # Inter-caller awareness fields (populated from slim caller background dicts) situation_summary: str = "" # 1-sentence summary for other callers communication_style: str = "" # Emotional register of the caller key_details: list[str] = field(default_factory=list) # Specific memorable details def _serialize_call_record(record: CallRecord) -> dict: return { "caller_type": record.caller_type, "caller_name": record.caller_name, "summary": record.summary, "transcript": record.transcript, "started_at": record.started_at, "ended_at": record.ended_at, "quality_signals": record.quality_signals, "situation_summary": record.situation_summary, "communication_style": record.communication_style, "key_details": record.key_details, } def _deserialize_call_record(data: dict) -> CallRecord: return CallRecord( caller_type=data["caller_type"], caller_name=data["caller_name"], summary=data.get("summary", ""), transcript=data.get("transcript", []), started_at=data.get("started_at", 0.0), ended_at=data.get("ended_at", 0.0), quality_signals=data.get("quality_signals", {}), situation_summary=data.get("situation_summary", ""), communication_style=data.get("communication_style", ""), key_details=data.get("key_details", []), ) def _assess_call_quality( conversation: list[dict], caller_hangup: bool = False, ) -> dict: """Compute heuristic quality signals for a completed call. No LLM needed. Returns a plain dict for storage in CallRecord.quality_signals and session.call_quality_signals.""" host_msgs = [m for m in conversation if m.get("role") in ("user", "host")] caller_msgs = [m for m in conversation if m.get("role") == "assistant"] exchange_count = len(conversation) caller_char_counts = [len(m["content"]) for m in caller_msgs] avg_response_length = ( round(sum(caller_char_counts) / len(caller_char_counts), 1) if caller_char_counts else 0.0 ) host_engagement = sum(1 for m in host_msgs if "?" in m["content"]) # Caller depth: responses > 50 chars after the first exchange caller_depth = sum(1 for m in caller_msgs[1:] if len(m["content"]) > 50) # Natural ending: True if the call did NOT end with [HANGUP] sentinel natural_ending = not caller_hangup return { "exchange_count": exchange_count, "avg_response_length": avg_response_length, "host_engagement": host_engagement, "caller_depth": caller_depth, "natural_ending": natural_ending, } # Generic reactions for when an earlier call has no details worth naming. # Interpolated as "...and you {reaction}." so each must agree with "you". SHOW_HISTORY_REACTIONS = [ "have been chewing on their call ever since", "didn't buy a word of it", "thought they got a raw deal from the host", "can't quite shake what they said", "wanted to reach through the radio and argue with them", "felt for them more than you expected to", "thought they were dead right and nobody backed them up", ] class Session: def __init__(self): self.id = str(uuid.uuid4())[:8] self.current_caller_key: str = None self.conversation: list[dict] = [] self.caller_backgrounds: dict[str, dict] = {} # Slim caller identity dicts, keyed by caller_key self.call_history: list[CallRecord] = [] self._call_started_at: float = 0.0 self.active_real_caller: dict | None = None self.ai_respond_mode: str = "manual" # "manual" or "auto" self.auto_followup: bool = False self.news_headlines: list = [] self.research_notes: dict[str, list] = {} self._research_task: asyncio.Task | None = None self.used_reasons: set[str] = set() # Track used caller reasons to prevent repeats self.call_quality_signals: list[dict] = [] # Per-call quality heuristics for tuning self._caller_hangup: bool = False # Set when [HANGUP] sentinel detected in current call self._wrapping_up: bool = False # Set via /api/wrap-up to gracefully wind down calls self._wrapup_exchanges: int = 0 # Track how many exchanges since wrap-up started 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 self.conversation = [] self._call_started_at = time.time() self._caller_hangup = False self._wrapping_up = False self._wrapup_exchanges = 0 def end_call(self): self.current_caller_key = None self.conversation = [] def add_message(self, role: str, content: str): self.conversation.append({"role": role, "content": content, "timestamp": time.time()}) def get_caller_background(self, caller_key: str) -> str: """Return the caller's situation string for UI display. Backgrounds are populated by _pregenerate_backgrounds at session start.""" bg = self.caller_backgrounds.get(caller_key) or {} return bg.get("situation", "") if isinstance(bg, dict) else "" def get_show_history(self) -> str: """Get formatted show history for AI caller prompts. Uses thematic matching to pick relevant previous callers to react to.""" if not self.call_history and not any(e.read_on_air for e in _listener_emails): return "" lines = ["EARLIER IN THE SHOW:"] for record in self.call_history: caller_type_label = "(real caller)" if record.caller_type == "real" else "(AI)" lines.append(f"- {record.caller_name} {caller_type_label}: {record.summary}") # Include emails that were read on the show read_emails = [e for e in _listener_emails if e.read_on_air] for em in read_emails: sender_name = em.sender.split("<")[0].strip().strip('"') if "<" in em.sender else "a listener" preview = em.body[:150] if len(em.body) > 150 else em.body lines.append(f"- A listener email from {sender_name} was read on air: \"{em.subject}\" — {preview}") # Thematic matching for inter-caller reactions if self.call_history: current_bg = self.caller_backgrounds.get(self.current_caller_key) best_target, best_score = self._find_thematic_match(current_bg) # Adaptive reaction frequency based on thematic match strength if best_score >= 3: reaction_chance = 0.60 elif best_score >= 1: reaction_chance = 0.35 else: reaction_chance = 0.15 if random.random() < reaction_chance and best_target: reaction = self._build_specific_reaction(current_bg, best_target) if random.random() < 0.30: lines.append(f"\nYOU HEARD {best_target.caller_name.upper()} EARLIER ON THE SHOW TONIGHT and you {reaction}. It reminded you of your own situation — bring it up early and tie it into your story. NOTE: You are NOT {best_target.caller_name} — you are a different caller who was listening.") else: lines.append(f"\nYOU HEARD {best_target.caller_name.upper()} EARLIER and you {reaction}. Mention it if it comes up naturally, but your call is about YOUR thing.") else: lines.append("You're aware of these but you're calling about YOUR thing, not theirs. Don't bring them up unless the host does.") return "\n".join(lines) def _find_thematic_match(self, current_bg) -> tuple: """Score previous callers against current caller for thematic relevance. Returns (best_target CallRecord, score).""" if not self.call_history: return None, 0 best_target = None best_score = 0 if isinstance(current_bg, dict): current_reason = current_bg.get("reason_calling", "") current_summary = current_bg.get("situation", "") else: current_reason = "" current_summary = "" current_words = set((current_reason + " " + current_summary).lower().split()) for record in self.call_history: score = 0 # Keyword overlap in situation summaries if record.situation_summary: record_words = set(record.situation_summary.lower().split()) overlap = current_words & record_words - {"the", "a", "an", "and", "or", "is", "was", "to", "in", "of", "for", "that", "it", "on", "with"} if len(overlap) >= 2: score += 2 elif len(overlap) >= 1: score += 1 if score > best_score: best_score = score best_target = record # If no thematic match, pick a random target for generic reactions if best_target is None: best_target = random.choice(self.call_history) return best_target, best_score def _build_specific_reaction(self, current_bg, target: 'CallRecord') -> str: """Build a reaction that references specific details from the target call.""" # If target has specific details, use them for a more specific reaction if target.key_details: detail = random.choice(target.key_details) specific_reactions = [ f"heard them talk about {detail} and has strong opinions about it", f"had something similar happen involving {detail}", f"completely disagrees with their take on {detail}", f"was thinking about what they said about {detail} and it reminded them of their own situation", f"can't stop thinking about the {detail} part", ] return random.choice(specific_reactions) # If target has a situation summary, use that if target.situation_summary: summary_reactions = [ f"heard about their situation and has been through something eerily similar", f"thinks they were completely wrong about their situation", f"felt personally called out by their story", f"wants to give them advice the host didn't", ] return random.choice(summary_reactions) # Fallback to generic reactions return random.choice(SHOW_HISTORY_REACTIONS) def get_conversation_summary(self) -> str: """Get a brief summary of conversation so far for context""" if len(self.conversation) <= 2: return "" summary_parts = [] for msg in self.conversation[-6:]: role = msg["role"] if role == "user" or role == "host": label = "Host" elif role.startswith("real_caller:"): label = role.split(":", 1)[1] elif role.startswith("ai_caller:"): label = role.split(":", 1)[1] elif role == "assistant": label = self.caller["name"] if self.caller else "Caller" else: label = role content = msg["content"] summary_parts.append( f'{label}: "{content[:100]}..."' if len(content) > 100 else f'{label}: "{content}"' ) return "\n".join(summary_parts) @property def caller(self) -> dict: 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"], "vibe": self.get_caller_background(self.current_caller_key), "tts_provider": base.get("tts_provider"), } return None 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 rotation: Sonnet biases toward familiar-sounding names when shown # the full 78-voice roster, so 12-ish voices dominated ~50% of TTS calls. # Pre-sample a 25-voice subset per show, excluding voices used in the last # 2 shows — this rotates the full pool over 5-6 shows. full_pool = [name for name in INWORLD_MALE_VOICES + INWORLD_FEMALE_VOICES if name not in BLACKLISTED_VOICES] recently_used = _recently_used_voices(n_shows=2) fresh_pool = [v for v in full_pool if v not in recently_used] or full_pool voice_roster = random.sample(fresh_pool, min(25, len(fresh_pool))) print(f"[Background] Voice subset: {len(voice_roster)} of {len(full_pool)} ({len(recently_used)} excluded as recent)") # Each active regular's appearance probability scales with shows-since-last # appearance: base 0.4, +0.3 per missed show, capped at 1.0. A regular who's # never appeared (or hasn't been seen in 2+ shows) is guaranteed tonight. active_regulars = regulars_v2.load_all_active_regulars() history = _load_lineup_history() def _shows_since_last(name: str) -> int: for i, record in enumerate(reversed(history)): if any(c.get("name") == name for c in record.get("lineup", [])): return i return 999 # never appeared regulars_for_tonight: list[dict] = [] for r in active_regulars: misses = _shows_since_last(r.name) prob = min(1.0, 0.4 + 0.3 * misses) if random.random() < prob: regulars_for_tonight.append( {"name": r.name, "voice": r.voice, "age": r.age, "lore": r.lore_body, "arc_state": r.arc_state, "_misses": misses, "_prob": prob} ) regulars_for_tonight = regulars_for_tonight[:3] if regulars_for_tonight: names = ", ".join(f"{r['name']}(miss={r['_misses']},p={r['_prob']:.2f})" for r in regulars_for_tonight) print(f"[Background] Regulars tonight: {names}") else: print("[Background] No regulars tonight — all walk-ins") # Strip internal fields before passing to caller_gen for r in regulars_for_tonight: r.pop("_misses", None) r.pop("_prob", None) 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)) weather_line = "cool desert night" try: async with asyncio.timeout(5): w = await _get_weather_for_town("alpine") if w: weather_line = f"Weather in Alpine right now: {w}" except Exception as e: print(f"[Background] Alpine weather lookup failed: {e}") base_ctx = { "date": datetime.now().strftime("%A, %B %d, %Y"), "weather": weather_line, "headlines": headlines, "recent_caller_summaries": self._get_recent_summaries(), "voice_roster": voice_roster, "theme": self.show_theme, } # Single batch of 10 so sonnet sees the full roster and can enforce # the anti-collision rule across all callers. Previously two parallel # batches of 6 were generated blind to each other, which produced # recurring archetype clusters (e.g. two BBQ callers, two taxidermists). ctx = {**base_ctx, "regulars_included": regulars_for_tonight, "caller_count": 10} try: identities = await caller_gen.generate_batch(ctx) except Exception as e: print(f"[Background] Batch failed: {e}") identities = [] # 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) 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) # Build memory-injection metadata for each regular: arc_state from the # Obsidian frontmatter (regulars_v2) + last few call summaries from # data/regulars.json. Without this, the dialog model treats every call # as the caller's first time and forgets prior conversations. regular_memory_by_name: dict[str, dict] = {} for r in regulars_for_tonight: persisted = regular_caller_service.get_by_name(r["name"]) prior_summaries: list[str] = [] if persisted: for entry in (persisted.get("call_history") or [])[-4:]: summary = entry.get("summary", "").strip() if summary: prior_summaries.append(summary[:400]) regular_memory_by_name[r["name"]] = { "is_regular": True, "arc_state": (r.get("arc_state") or "").strip(), "prior_summaries": prior_summaries, } backgrounds: dict[str, dict] = {} caller_keys = list(CALLER_BASES.keys()) # ["1"-"9","0"] for i, identity in enumerate(ordered[:10]): key = caller_keys[i] bg = { "name": identity.name, "age": identity.age, "voice": identity.voice_resolved, "location": identity.location, "identity": identity.identity, "situation": identity.situation, "reason_calling": identity.reason_calling, "opening_line": identity.opening_line, "secret_want": identity.secret_want, "specific_details": identity.specific_details, "emotional_register": identity.emotional_register, } if identity.name in regular_memory_by_name: bg.update(regular_memory_by_name[identity.name]) backgrounds[key] = bg print(f"[Background] Built {len(backgrounds)} callers (from {len(identities)} raw, {len(regular_idents)} regulars locked)") _save_lineup_to_history(backgrounds) 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 caller name+situation strings from the last 2 lineups, so Sonnet can avoid repeating archetypes. Empty list on first run.""" history = _load_lineup_history() recent = history[-2:] summaries: list[str] = [] for record in recent: for caller in record.get("lineup", []): name = caller.get("name", "") situation = (caller.get("situation") or "")[:160] if name and situation: summaries.append(f"{name}: {situation}") return summaries def reset(self): """Reset session - clears all caller backgrounds for fresh personalities""" self.caller_backgrounds = {} self.current_caller_key = None self.conversation = [] self.call_history = [] self.active_real_caller = None self.ai_respond_mode = "manual" self.auto_followup = False self.news_headlines = [] self.research_notes = {} if self._research_task and not self._research_task.done(): self._research_task.cancel() self._research_task = None self.call_quality_signals = [] self._wrapping_up = False self._wrapup_exchanges = 0 self.caller_queue = [] self.used_reasons = set() self.intern_monitoring = True intern_service.stop_monitoring() intern_service.new_show() cost_tracker.reset() _randomize_callers() self.id = str(uuid.uuid4())[:8] names = [CALLER_BASES[k]["name"] for k in sorted(CALLER_BASES.keys())] print(f"[Session] Reset - new session ID: {self.id}, callers: {', '.join(names)}") session = Session() caller_service = CallerService() _ai_response_lock = asyncio.Lock() # Prevents concurrent AI responses _session_epoch = 0 # Increments on hangup/call start — stale tasks check this _show_on_air = False # Controls whether phone calls are accepted or get off-air message _caller_line_ready = False # True when ngrok tunnel is up and SignalWire webhook is pointed at it _hold_music_tasks: dict[str, asyncio.Task] = {} # caller_id -> hold music streaming task def _stop_hold_music(caller_id: str): task = _hold_music_tasks.pop(caller_id, None) if task and not task.done(): task.cancel() print(f"[Hold Music] Stopped for {caller_id}") async def _stream_hold_music(caller_id: str): """Stream music tracks to a queued caller until they go on air or disconnect.""" import librosa tracks = [] if settings.music_dir.exists(): for ext in ('*.wav', '*.mp3', '*.flac'): tracks.extend(settings.music_dir.glob(ext)) if not tracks: print("[Hold Music] No tracks found in music directory") return random.shuffle(tracks) track_idx = 0 print(f"[Hold Music] Starting for {caller_id} ({len(tracks)} tracks available)") try: while caller_id in caller_service._websockets: track = tracks[track_idx % len(tracks)] track_idx += 1 print(f"[Hold Music] Playing '{track.stem}' for {caller_id}") audio, sr = librosa.load(str(track), sr=24000, mono=True) # Reduce volume to 40% audio = audio * 0.4 audio_int16 = (audio * 32767).astype(np.int16) await caller_service.stream_audio_to_caller(caller_id, audio_int16.tobytes(), 24000) # Brief pause between tracks await asyncio.sleep(1.0) except asyncio.CancelledError: pass except Exception as e: print(f"[Hold Music] Error for {caller_id}: {e}") finally: _hold_music_tasks.pop(caller_id, None) # --- Session Checkpoint --- CHECKPOINT_FILE = Path(__file__).parent.parent / "data" / "session_checkpoint.json" CHECKPOINT_MAX_AGE = 12 * 3600 # Ignore checkpoints older than 12 hours # Restore the persisted caller lineup only if a show is in progress (calls already # made) AND the checkpoint is recent. Otherwise the next session gets a fresh roster. CHECKPOINT_LINEUP_MAX_AGE = 3600 # 1 hour # --- Lineup History (for anti-repeat context across sessions) --- LINEUP_HISTORY_FILE = Path(__file__).parent.parent / "data" / "caller_lineups.json" LINEUP_HISTORY_MAX = 10 def _load_lineup_history() -> list[dict]: if not LINEUP_HISTORY_FILE.exists(): return [] try: with open(LINEUP_HISTORY_FILE) as f: return json.load(f).get("lineups", []) except Exception as e: print(f"[LineupHistory] Load failed: {e}") return [] def _recently_used_voices(n_shows: int = 2) -> set[str]: """Voices assigned in the last N show lineups — caller_gen excludes these from the next show's voice subset to force rotation through the full pool.""" history = _load_lineup_history() recent = history[-n_shows:] if history else [] voices: set[str] = set() for record in recent: for caller in record.get("lineup", []): v = caller.get("voice") if v: voices.add(v) return voices def _save_lineup_to_history(backgrounds: dict): """Append the active lineup so future shows can avoid repeating archetypes and rotate voices through the full pool. Stores name + situation + voice.""" lineup = [ {"name": v.get("name", ""), "situation": v.get("situation", ""), "voice": v.get("voice", "")} for v in backgrounds.values() if isinstance(v, dict) and v.get("name") ] if not lineup: return history = _load_lineup_history() history.append({"timestamp": time.time(), "lineup": lineup}) history = history[-LINEUP_HISTORY_MAX:] try: LINEUP_HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True) with open(LINEUP_HISTORY_FILE, "w") as f: json.dump({"lineups": history}, f, indent=2) except Exception as e: print(f"[LineupHistory] Save failed: {e}") def _save_checkpoint(): try: CHECKPOINT_FILE.parent.mkdir(parents=True, exist_ok=True) caller_bases_snapshot = {} for key, base in CALLER_BASES.items(): caller_bases_snapshot[key] = { "name": base.get("name"), "voice": base.get("voice"), "returning": base.get("returning", False), "regular_id": base.get("regular_id"), } data = { "session_id": session.id, "call_history": [_serialize_call_record(r) for r in session.call_history], "caller_backgrounds": session.caller_backgrounds, "used_reasons": list(session.used_reasons), "ai_respond_mode": session.ai_respond_mode, "auto_followup": session.auto_followup, "news_headlines": session.news_headlines, "research_notes": session.research_notes, "caller_bases": caller_bases_snapshot, "call_quality_signals": session.call_quality_signals, "caller_queue": session.caller_queue, "intern_monitoring": session.intern_monitoring, "costs": cost_tracker.get_live_summary(), "cost_records": { "llm": [asdict(r) for r in cost_tracker.llm_records], "tts": [asdict(r) for r in cost_tracker.tts_records], }, "saved_at": time.time(), } with open(CHECKPOINT_FILE, "w") as f: json.dump(data, f, indent=2) print(f"[Checkpoint] Saved session {session.id} ({len(session.call_history)} calls)") except Exception as e: print(f"[Checkpoint] Failed to save: {e}") def _load_checkpoint() -> bool: if not CHECKPOINT_FILE.exists(): return False try: with open(CHECKPOINT_FILE) as f: data = json.load(f) age = time.time() - data.get("saved_at", 0) if age > CHECKPOINT_MAX_AGE: print(f"[Checkpoint] Stale ({age / 3600:.1f}h old), starting fresh") return False session.id = data["session_id"] session.call_history = [_deserialize_call_record(r) for r in data.get("call_history", [])] # Only restore the caller lineup if a show is genuinely mid-flight (calls # already made) and the checkpoint is fresh enough that we're likely # recovering from a crash rather than starting a new session. Otherwise # the next show would inherit the previous lineup verbatim. raw_bgs = data.get("caller_backgrounds", {}) calls_made = len(session.call_history) mid_show = calls_made > 0 and age < CHECKPOINT_LINEUP_MAX_AGE if mid_show: session.caller_backgrounds = { k: v for k, v in raw_bgs.items() if isinstance(v, dict) and "identity" in v and "situation" in v } else: session.caller_backgrounds = {} if raw_bgs: print(f"[Checkpoint] Dropping persisted lineup (calls={calls_made}, age={age/60:.0f}m) — fresh roster will be generated") session.used_reasons = set(data.get("used_reasons", [])) session.ai_respond_mode = data.get("ai_respond_mode", "manual") session.auto_followup = data.get("auto_followup", False) session.news_headlines = data.get("news_headlines", []) session.research_notes = data.get("research_notes", {}) session.call_quality_signals = data.get("call_quality_signals", []) session.caller_queue = data.get("caller_queue", []) session.intern_monitoring = data.get("intern_monitoring", True) for key, snapshot in data.get("caller_bases", {}).items(): if key in CALLER_BASES: CALLER_BASES[key]["name"] = snapshot["name"] CALLER_BASES[key]["voice"] = snapshot["voice"] CALLER_BASES[key]["returning"] = snapshot.get("returning", False) CALLER_BASES[key]["regular_id"] = snapshot.get("regular_id") # Restore cost tracker records cost_records = data.get("cost_records", {}) if cost_records: cost_tracker.reset() for r in cost_records.get("llm", []): cost_tracker.llm_records.append(LLMCallRecord(**r)) for r in cost_records.get("tts", []): cost_tracker.tts_records.append(TTSCallRecord(**r)) # Rebuild running totals from restored records for r in cost_tracker.llm_records: cost_tracker._llm_cost += r.cost_usd cost_tracker._llm_calls += 1 cost_tracker._prompt_tokens += r.prompt_tokens cost_tracker._completion_tokens += r.completion_tokens cost_tracker._total_tokens += r.total_tokens cat = cost_tracker._by_category.setdefault(r.category, {"cost": 0.0, "calls": 0, "tokens": 0}) cat["cost"] += r.cost_usd cat["calls"] += 1 cat["tokens"] += r.total_tokens for r in cost_tracker.tts_records: cost_tracker._tts_cost += r.cost_usd print(f"[Checkpoint] Restored {len(cost_tracker.llm_records)} LLM + {len(cost_tracker.tts_records)} TTS cost records") mins = age / 60 print(f"[Checkpoint] Restored session {session.id} ({len(session.call_history)} calls, {mins:.0f}m old)") return True except Exception as e: print(f"[Checkpoint] Failed to load: {e}") return False # --- Voicemail --- VOICEMAILS_DIR = Path(__file__).parent.parent / "data" / "voicemails" VOICEMAILS_SAVED_DIR = Path(__file__).parent.parent / "voicemails" VOICEMAILS_META = Path(__file__).parent.parent / "data" / "voicemails.json" @dataclass class Voicemail: id: str phone: str timestamp: float duration: int file_path: str listened: bool = False transcript: str = "" _voicemails: list[Voicemail] = [] _deleted_vm_timestamps: set[int] = set() def _load_voicemails(): global _voicemails, _deleted_vm_timestamps if VOICEMAILS_META.exists(): try: with open(VOICEMAILS_META) as f: data = json.load(f) _voicemails = [ Voicemail( id=v["id"], phone=v["phone"], timestamp=v["timestamp"], duration=v["duration"], file_path=v["file_path"], listened=v.get("listened", False), transcript=v.get("transcript", ""), ) for v in data.get("voicemails", []) ] _deleted_vm_timestamps = set(data.get("deleted_timestamps", [])) print(f"[Voicemail] Loaded {len(_voicemails)} voicemails") except Exception as e: print(f"[Voicemail] Failed to load: {e}") _voicemails = [] def _save_voicemails(): try: VOICEMAILS_META.parent.mkdir(parents=True, exist_ok=True) data = { "voicemails": [ { "id": v.id, "phone": v.phone, "timestamp": v.timestamp, "duration": v.duration, "file_path": v.file_path, "listened": v.listened, "transcript": v.transcript, } for v in _voicemails ], "deleted_timestamps": list(_deleted_vm_timestamps), } with open(VOICEMAILS_META, "w") as f: json.dump(data, f, indent=2) except Exception as e: print(f"[Voicemail] Failed to save: {e}") # --- News & Research Helpers --- async def _fetch_session_headlines(): try: session.news_headlines = await news_service.get_headlines() print(f"[News] Loaded {len(session.news_headlines)} headlines for session") except Exception as e: print(f"[News] Failed to load headlines: {e}") async def _background_research(text: str): keywords = extract_keywords(text) if not keywords: return query = " ".join(keywords) if query.lower() in session.research_notes: return try: async with asyncio.timeout(8): results = await news_service.search_topic(query) if results: session.research_notes[query.lower()] = results print(f"[Research] Found {len(results)} results for '{query}'") except TimeoutError: print(f"[Research] Timed out for '{query}'") except Exception as e: print(f"[Research] Error: {e}") def _build_news_context() -> tuple[str, str]: """Build context from cached news/research only — never does network calls. Each caller gets a random subset of headlines so they don't all reference the same thing.""" news_context = "" if session.news_headlines and random.random() < 0.5: # Random 2-3 headlines, not the same 6 every time pool = list(session.news_headlines) random.shuffle(pool) news_context = news_service.format_headlines_for_prompt(pool[:random.randint(2, 3)]) research_context = "" if session.research_notes: all_items = [] for items in session.research_notes.values(): all_items.extend(items) seen = set() unique = [] for item in all_items: if item.title not in seen: seen.add(item.title) unique.append(item) random.shuffle(unique) research_context = news_service.format_headlines_for_prompt(unique[:3]) return news_context, research_context async def _transcribe_voicemail(vm: Voicemail) -> str: """Transcribe a voicemail so Devon and the callers can react to it. Whisper is blocking CPU work — run it in a thread so a voicemail arriving mid-show can't stall the event loop (and with it the live audio). """ fp = Path(vm.file_path) if not fp.exists(): return "" try: audio_bytes = fp.read_bytes() loop = asyncio.get_running_loop() text = await loop.run_in_executor( None, lambda: asyncio.run(transcribe_audio(audio_bytes)) ) vm.transcript = (text or "").strip() _save_voicemails() if vm.transcript: print(f"[Voicemail] Transcribed {fp.name}: {vm.transcript[:80]}") else: print(f"[Voicemail] Transcribed {fp.name}: (no speech detected)") return vm.transcript except Exception as e: print(f"[Voicemail] Transcription failed for {fp.name}: {e}") return "" async def _backfill_voicemail_transcripts(): """Transcribe any voicemails that predate transcription support.""" pending = [v for v in _voicemails if not v.transcript and Path(v.file_path).exists()] if not pending: return print(f"[Voicemail] Backfilling transcripts for {len(pending)} voicemail(s)...") for vm in pending: await _transcribe_voicemail(vm) async def _sync_signalwire_voicemails(): """Pull any recordings from SignalWire that aren't already tracked locally. Checks both the top-level Recordings endpoint AND per-call recordings (Record verb recordings don't always appear in the top-level list).""" if not settings.signalwire_project_id or not settings.signalwire_token: return try: from datetime import datetime as _dt auth = (settings.signalwire_project_id, settings.signalwire_token) base = f"https://{settings.signalwire_space}/api/laml/2010-04-01/Accounts/{settings.signalwire_project_id}" existing_timestamps = {int(v.timestamp) for v in _voicemails} | _deleted_vm_timestamps all_recordings = [] async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client: # 1. Top-level recordings resp = await client.get(f"{base}/Recordings.json", auth=auth) resp.raise_for_status() for rec in resp.json().get("recordings", []): rec["_source"] = "top-level" all_recordings.append(rec) # 2. Check recent calls for per-call recordings (last 20 calls) calls_resp = await client.get(f"{base}/Calls.json?PageSize=20", auth=auth) if calls_resp.status_code == 200: for call in calls_resp.json().get("calls", []): call_sid = call.get("sid", "") call_from = call.get("from", "Unknown") rec_resp = await client.get(f"{base}/Calls/{call_sid}/Recordings", auth=auth) if rec_resp.status_code == 200: for rec in rec_resp.json().get("recordings", []): rec["_caller_phone"] = call_from rec["_source"] = "per-call" all_recordings.append(rec) # Deduplicate by recording SID seen_sids = set() unique_recordings = [] for rec in all_recordings: sid = rec.get("sid", "") if sid not in seen_sids: seen_sids.add(sid) unique_recordings.append(rec) synced = 0 for rec in unique_recordings: call_sid = rec.get("call_sid", "") duration = int(rec.get("duration", 0)) date_created = rec.get("date_created", "") if duration < 2: continue try: ts = int(_dt.strptime(date_created, "%a, %d %b %Y %H:%M:%S %z").timestamp()) except (ValueError, TypeError): ts = int(time.time()) if ts in existing_timestamps: continue # Get caller phone — may already be embedded from per-call lookup caller_phone = rec.get("_caller_phone", "Unknown") if caller_phone == "Unknown" and call_sid: try: async with httpx.AsyncClient(timeout=15.0) as client: call_resp = await client.get(f"{base}/Calls/{call_sid}.json", auth=auth) if call_resp.status_code == 200: caller_phone = call_resp.json().get("from", "Unknown") except Exception: pass rec_uri = rec.get("uri", "").replace(".json", ".wav") rec_url = f"https://{settings.signalwire_space}{rec_uri}" await _download_voicemail(rec_url, caller_phone, duration) if _voicemails and _voicemails[-1].phone == caller_phone: _voicemails[-1].timestamp = ts _save_voicemails() existing_timestamps.add(ts) synced += 1 if synced: print(f"[Voicemail] Synced {synced} recording(s) from SignalWire") else: print(f"[Voicemail] No new recordings found ({len(unique_recordings)} total checked)") except Exception as e: print(f"[Voicemail] SignalWire sync failed: {e}") # --- Lifecycle --- @app.on_event("startup") async def startup(): """Pre-generate caller backgrounds on server start""" _load_voicemails() _load_emails() asyncio.create_task(_sync_signalwire_voicemails()) asyncio.create_task(_backfill_voicemail_transcripts()) asyncio.create_task(_poll_imap_emails()) restored = _load_checkpoint() if not restored or not session.caller_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() @app.on_event("shutdown") async def shutdown(): """Clean up resources on server shutdown""" global _host_audio_task _save_checkpoint() print("[Server] Shutting down — cleaning up resources...") _update_on_air_cdn(False) _stop_ngrok() # Stop host mic streaming audio_service.stop_host_stream() # Cancel host audio sender task if _host_audio_task and not _host_audio_task.done(): _host_audio_task.cancel() try: await _host_audio_task except (asyncio.CancelledError, Exception): pass _host_audio_task = None # Disconnect all active callers for caller_id in list(caller_service.active_calls.keys()): caller_service.hangup(caller_id) caller_service.reset() await news_service.close() print("[Server] Cleanup complete") # --- Static Files --- frontend_dir = Path(__file__).parent.parent / "frontend" app.mount("/css", StaticFiles(directory=frontend_dir / "css"), name="css") app.mount("/js", StaticFiles(directory=frontend_dir / "js"), name="js") app.mount("/images", StaticFiles(directory=frontend_dir / "images"), name="images") @app.get("/costs") async def costs_page(): return FileResponse(frontend_dir / "costs.html") @app.get("/") async def index(): return FileResponse(frontend_dir / "index.html") # --- Ngrok Tunnel Management --- _ngrok_process: subprocess.Popen | None = None _ngrok_domain = "shana-chromoplasmic-noneligibly.ngrok-free.dev" _signalwire_phone_sid = "12ef9c34-976d-4cff-814e-d740415dd0df" def _start_ngrok(): """Start ngrok tunnel and update SignalWire webhook to point to it.""" global _ngrok_process, _caller_line_ready if _ngrok_process and _ngrok_process.poll() is None: print("[Ngrok] Already running") _caller_line_ready = True return True _caller_line_ready = False try: _ngrok_process = subprocess.Popen( ["ngrok", "http", "8000", f"--domain={_ngrok_domain}", "--log=stdout", "--log-format=json"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) # Wait for tunnel to be ready import time as _time for _ in range(20): _time.sleep(0.5) try: resp = httpx.get("http://127.0.0.1:4040/api/tunnels", timeout=2) tunnels = resp.json().get("tunnels", []) if tunnels: public_url = tunnels[0]["public_url"] print(f"[Ngrok] Tunnel ready: {public_url}") _update_signalwire_webhook(public_url) _caller_line_ready = True return True except Exception: continue print("[Ngrok] Timed out waiting for tunnel") return False except FileNotFoundError: print("[Ngrok] ngrok binary not found") return False except Exception as e: print(f"[Ngrok] Failed to start: {e}") return False def _stop_ngrok(): """Stop ngrok tunnel and restore SignalWire webhook to production URL.""" global _ngrok_process, _caller_line_ready _caller_line_ready = False _restore_signalwire_webhook() if _ngrok_process and _ngrok_process.poll() is None: _ngrok_process.terminate() try: _ngrok_process.wait(timeout=5) except subprocess.TimeoutExpired: _ngrok_process.kill() print("[Ngrok] Stopped") _ngrok_process = None def _update_signalwire_webhook(ngrok_url: str): """Point SignalWire phone number webhook to ngrok tunnel.""" if not settings.signalwire_project_id or not settings.signalwire_token: return try: url = (f"https://{settings.signalwire_space}/api/laml/2010-04-01/Accounts/" f"{settings.signalwire_project_id}/IncomingPhoneNumbers/{_signalwire_phone_sid}.json") voice_url = f"{ngrok_url}/api/signalwire/voice" resp = httpx.post(url, data={ "VoiceUrl": voice_url, "VoiceFallbackUrl": voice_url, }, auth=(settings.signalwire_project_id, settings.signalwire_token), timeout=10) if resp.status_code == 200: print(f"[SignalWire] Webhook updated -> {voice_url}") else: print(f"[SignalWire] Failed to update webhook: {resp.status_code} {resp.text[:200]}") except Exception as e: print(f"[SignalWire] Webhook update error: {e}") def _restore_signalwire_webhook(): """Restore SignalWire webhook to production URL (voicemail when off air).""" if not settings.signalwire_project_id or not settings.signalwire_token: return try: url = (f"https://{settings.signalwire_space}/api/laml/2010-04-01/Accounts/" f"{settings.signalwire_project_id}/IncomingPhoneNumbers/{_signalwire_phone_sid}.json") prod_url = "https://lukeattheroost.com/api/signalwire/voice" resp = httpx.post(url, data={ "VoiceUrl": prod_url, "VoiceFallbackUrl": f"https://lukeattheroost.com/voicemail.xml", }, auth=(settings.signalwire_project_id, settings.signalwire_token), timeout=10) if resp.status_code == 200: print(f"[SignalWire] Webhook restored -> {prod_url}") else: print(f"[SignalWire] Failed to restore webhook: {resp.status_code}") except Exception as e: print(f"[SignalWire] Webhook restore error: {e}") # --- On-Air Toggle --- # BunnyCDN config for public on-air status _BUNNY_STORAGE_ZONE = "lukeattheroost" _BUNNY_STORAGE_KEY = os.getenv("BUNNY_STORAGE_KEY", "") _BUNNY_STORAGE_REGION = "la" _BUNNY_ACCOUNT_KEY = os.getenv("BUNNY_ACCOUNT_KEY", "") def _update_on_air_cdn(on_air: bool): """Upload on-air status to BunnyCDN so the public website can poll it.""" from datetime import datetime, timezone data = {"on_air": on_air} if on_air: data["since"] = datetime.now(timezone.utc).isoformat() url = f"https://{_BUNNY_STORAGE_REGION}.storage.bunnycdn.com/{_BUNNY_STORAGE_ZONE}/status.json" try: resp = httpx.put(url, content=json.dumps(data), headers={ "AccessKey": _BUNNY_STORAGE_KEY, "Content-Type": "application/json", }, timeout=5) if resp.status_code == 201: print(f"[CDN] On-air status updated: {on_air}") else: print(f"[CDN] Failed to update on-air status: {resp.status_code}") return httpx.get( "https://api.bunny.net/purge", params={"url": "https://cdn.lukeattheroost.com/status.json", "async": "false"}, headers={"AccessKey": _BUNNY_ACCOUNT_KEY}, timeout=10, ) print(f"[CDN] Cache purged") except Exception as e: print(f"[CDN] Error updating on-air status: {e}") @app.post("/api/on-air") async def set_on_air(state: dict): """Toggle whether the show is on air (accepting phone calls). Also toggles recording.""" global _show_on_air _show_on_air = bool(state.get("on_air", False)) print(f"[Show] On-air: {_show_on_air}") if _show_on_air: # Reset REAPER state to dialog for fresh show try: from .services.audio import _write_reaper_state _write_reaper_state("dialog") except Exception: pass # Auto-start recording FIRST (before host stream, which takes over mic capture) if audio_service.stem_recorder is None: try: from datetime import datetime dir_name = datetime.now().strftime("%Y-%m-%d_%H%M%S") recordings_dir = Path("recordings") / dir_name import sounddevice as sd device_info = sd.query_devices(audio_service.output_device) if audio_service.output_device is not None else None sr = int(device_info["default_samplerate"]) if device_info else 48000 recorder = StemRecorder(recordings_dir, sample_rate=sr) recorder.start() audio_service.stem_recorder = recorder audio_service.start_stem_mic() add_log(f"Stem recording auto-started -> {recordings_dir}") except Exception as e: print(f"[Show] Failed to auto-start recording: {e}") _start_host_audio_sender() # Host stream takes over mic capture (closes stem_mic if active) audio_service.start_host_stream(_host_audio_sync_callback) else: audio_service.stop_host_stream() # Auto-stop recording if audio_service.stem_recorder is not None: try: audio_service.stop_stem_mic() stems_dir = audio_service.stem_recorder.output_dir paths = audio_service.stem_recorder.stop() audio_service.stem_recorder = None add_log(f"Stem recording auto-stopped. Running post-production...") import subprocess, sys python = sys.executable output_file = stems_dir / "episode.mp3" def _run_postprod(): try: result = subprocess.run( [python, "postprod.py", str(stems_dir), "-o", "episode.mp3"], capture_output=True, text=True, timeout=600, ) if result.returncode == 0: add_log(f"Post-production complete -> {output_file}") else: add_log(f"Post-production failed: {result.stderr[:300]}") except Exception as e: add_log(f"Post-production error: {e}") threading.Thread(target=_run_postprod, daemon=True).start() except Exception as e: print(f"[Show] Failed to auto-stop recording: {e}") threading.Thread(target=_update_on_air_cdn, args=(_show_on_air,), daemon=True).start() if _show_on_air: threading.Thread(target=_start_ngrok, daemon=True).start() else: threading.Thread(target=_stop_ngrok, daemon=True).start() return {"on_air": _show_on_air, "recording": audio_service.stem_recorder is not None, "caller_line_ready": _caller_line_ready} @app.get("/api/on-air") async def get_on_air(): return {"on_air": _show_on_air, "recording": audio_service.stem_recorder is not None, "caller_line_ready": _caller_line_ready} # --- SignalWire Endpoints --- @app.post("/api/signalwire/voice") async def signalwire_voice_webhook(request: Request): """Handle inbound call from SignalWire — return XML to start bidirectional stream""" form = await request.form() caller_phone = form.get("From", "Unknown") call_sid = form.get("CallSid", "") print(f"[SignalWire] Inbound call from {caller_phone} (CallSid: {call_sid})") if not _show_on_air: print(f"[SignalWire] Show is off air — offering voicemail to {caller_phone}") # Derive host from stream URL config if available, otherwise from request if settings.signalwire_stream_url: from urllib.parse import urlparse host = urlparse(settings.signalwire_stream_url).hostname else: host = request.headers.get("host", "radioshow.macneilmediagroup.com") xml = f""" Luke at the Roost is off the air right now. Leave a message after the beep and we may play it on the next show! Thank you for calling. Goodbye! """ return Response(content=xml, media_type="application/xml") # Use dedicated stream URL (ngrok) if configured, otherwise derive from request if settings.signalwire_stream_url: stream_url = settings.signalwire_stream_url else: host = request.headers.get("host", "radioshow.macneilmediagroup.com") stream_url = f"wss://{host}/api/signalwire/stream" xml = f""" You've reached Luke at the Roost. Hold tight, we'll get you on the air. """ return Response(content=xml, media_type="application/xml") @app.post("/api/signalwire/voicemail-complete") async def signalwire_voicemail_complete(request: Request): form = await request.form() recording_url = form.get("RecordingUrl", "") caller_phone = form.get("From", "Unknown") duration = int(form.get("RecordingDuration", "0")) print(f"[Voicemail] Recording complete from {caller_phone} ({duration}s): {recording_url}") if recording_url: asyncio.create_task(_download_voicemail(recording_url, caller_phone, duration)) xml = 'Thank you for calling. Goodbye!' return Response(content=xml, media_type="application/xml") async def _download_voicemail(recording_url: str, caller_phone: str, duration: int): try: VOICEMAILS_DIR.mkdir(parents=True, exist_ok=True) ts = int(time.time()) safe_phone = caller_phone.replace("+", "").replace(" ", "") # Determine extension from URL ext = Path(recording_url.split("?")[0]).suffix or ".wav" filename = f"{ts}_{safe_phone}{ext}" filepath = VOICEMAILS_DIR / filename # Try downloading without auth first (pre-signed URL), fall back to basic auth auth = (settings.signalwire_project_id, settings.signalwire_token) async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client: resp = await client.get(recording_url) if resp.status_code in (401, 403): resp = await client.get(recording_url, auth=auth) resp.raise_for_status() with open(filepath, "wb") as f: f.write(resp.content) vm = Voicemail( id=str(uuid.uuid4())[:8], phone=caller_phone, timestamp=ts, duration=duration, file_path=str(filepath), ) _voicemails.append(vm) _save_voicemails() print(f"[Voicemail] Saved {filename} ({duration}s) from {caller_phone}") # Transcribe in the background so it's ready the moment the host airs it asyncio.create_task(_transcribe_voicemail(vm)) except Exception as e: print(f"[Voicemail] Failed to download recording: {e}") # --- Voicemail API --- @app.get("/api/voicemails") async def list_voicemails(): return [ { "id": v.id, "phone": v.phone, "timestamp": v.timestamp, "duration": v.duration, "listened": v.listened, "transcript": v.transcript, } for v in sorted(_voicemails, key=lambda v: v.timestamp, reverse=True) ] @app.get("/api/voicemail/{vm_id}/audio") async def get_voicemail_audio(vm_id: str): vm = next((v for v in _voicemails if v.id == vm_id), None) if not vm: raise HTTPException(status_code=404, detail="Voicemail not found") fp = Path(vm.file_path) if not fp.exists(): raise HTTPException(status_code=404, detail="Audio file missing") media_type = "audio/wav" if fp.suffix == ".wav" else "audio/mpeg" return FileResponse(fp, media_type=media_type, filename=fp.name) @app.post("/api/voicemail/{vm_id}/play-on-air") async def play_voicemail_on_air(vm_id: str): vm = next((v for v in _voicemails if v.id == vm_id), None) if not vm: raise HTTPException(status_code=404, detail="Voicemail not found") fp = Path(vm.file_path) if not fp.exists(): raise HTTPException(status_code=404, detail="Audio file missing") # Devon (and the AI callers) only perceive session.conversation, so the # voicemail has to land there as text or they're deaf to it. if vm.transcript: session.add_message(f"voicemail:{vm.phone}", vm.transcript) else: session.add_message( f"voicemail:{vm.phone}", f"(A {vm.duration}s voicemail from {vm.phone} just played on air — transcript unavailable.)", ) def _play(): import librosa audio, sr = librosa.load(str(fp), sr=24000, mono=True) audio_int16 = (audio * 32767).astype(np.int16) audio_service.play_caller_audio(audio_int16.tobytes(), 24000) thread = threading.Thread(target=_play, daemon=True) thread.start() vm.listened = True _save_voicemails() return {"status": "playing"} @app.post("/api/voicemail/{vm_id}/mark-listened") async def mark_voicemail_listened(vm_id: str): vm = next((v for v in _voicemails if v.id == vm_id), None) if not vm: raise HTTPException(status_code=404, detail="Voicemail not found") vm.listened = True _save_voicemails() return {"status": "ok"} @app.post("/api/voicemail/{vm_id}/save") async def save_voicemail(vm_id: str): vm = next((v for v in _voicemails if v.id == vm_id), None) if not vm: raise HTTPException(status_code=404, detail="Voicemail not found") fp = Path(vm.file_path) if not fp.exists(): raise HTTPException(status_code=404, detail="Audio file missing") VOICEMAILS_SAVED_DIR.mkdir(parents=True, exist_ok=True) dest = VOICEMAILS_SAVED_DIR / fp.name import shutil shutil.copy2(fp, dest) print(f"[Voicemail] Saved {fp.name} to archive") return {"status": "saved", "path": str(dest)} @app.delete("/api/voicemail/{vm_id}") async def delete_voicemail(vm_id: str): vm = next((v for v in _voicemails if v.id == vm_id), None) if not vm: raise HTTPException(status_code=404, detail="Voicemail not found") _deleted_vm_timestamps.add(int(vm.timestamp)) fp = Path(vm.file_path) if fp.exists(): fp.unlink() _voicemails.remove(vm) _save_voicemails() return {"status": "deleted"} # --- Listener Emails --- EMAILS_META = Path(__file__).parent.parent / "data" / "emails.json" @dataclass class ListenerEmail: id: str sender: str subject: str body: str timestamp: float read_on_air: bool = False _listener_emails: list[ListenerEmail] = [] def _load_emails(): global _listener_emails if EMAILS_META.exists(): try: with open(EMAILS_META) as f: data = json.load(f) _listener_emails = [ ListenerEmail( id=e["id"], sender=e["sender"], subject=e["subject"], body=e["body"], timestamp=e["timestamp"], read_on_air=e.get("read_on_air", False), ) for e in data.get("emails", []) ] print(f"[Email] Loaded {len(_listener_emails)} emails") except Exception as e: print(f"[Email] Failed to load: {e}") _listener_emails = [] def _save_emails(): try: EMAILS_META.parent.mkdir(parents=True, exist_ok=True) data = { "emails": [ { "id": e.id, "sender": e.sender, "subject": e.subject, "body": e.body, "timestamp": e.timestamp, "read_on_air": e.read_on_air, } for e in _listener_emails ], } with open(EMAILS_META, "w") as f: json.dump(data, f, indent=2) except Exception as exc: print(f"[Email] Failed to save: {exc}") async def _poll_imap_emails(): """Background task: poll IMAP every 30s for new listener emails""" import imaplib import email as email_lib from email.header import decode_header host = settings.submissions_imap_host user = settings.submissions_imap_user passwd = settings.submissions_imap_pass if not host or not user or not passwd: print("[Email] IMAP not configured, skipping email polling") return while True: try: mail = imaplib.IMAP4_SSL(host, 993) mail.login(user, passwd) mail.select("INBOX") _, msg_nums = mail.search(None, "UNSEEN") if msg_nums[0]: for num in msg_nums[0].split(): _, msg_data = mail.fetch(num, "(RFC822)") raw = msg_data[0][1] msg = email_lib.message_from_bytes(raw) # Decode sender from_raw = msg.get("From", "Unknown") # Decode subject subj_raw = msg.get("Subject", "(no subject)") decoded_parts = decode_header(subj_raw) subject = "" for part, charset in decoded_parts: if isinstance(part, bytes): subject += part.decode(charset or "utf-8", errors="replace") else: subject += part # Extract plain text body body = "" if msg.is_multipart(): for part in msg.walk(): if part.get_content_type() == "text/plain": payload = part.get_payload(decode=True) if payload: charset = part.get_content_charset() or "utf-8" body = payload.decode(charset, errors="replace") break else: payload = msg.get_payload(decode=True) if payload: charset = msg.get_content_charset() or "utf-8" body = payload.decode(charset, errors="replace") body = body.strip() if not body: continue # Parse timestamp from email Date header from email.utils import parsedate_to_datetime try: ts = parsedate_to_datetime(msg.get("Date", "")).timestamp() except Exception: ts = time.time() em = ListenerEmail( id=str(uuid.uuid4())[:8], sender=from_raw, subject=subject, body=body, timestamp=ts, ) _listener_emails.append(em) print(f"[Email] New email from {from_raw}: {subject[:50]}") # Mark as SEEN (already done by fetch with UNSEEN filter) mail.store(num, "+FLAGS", "\\Seen") _save_emails() mail.logout() except Exception as exc: print(f"[Email] IMAP poll error: {exc}") await asyncio.sleep(30) @app.get("/api/emails") async def list_emails(): return [ { "id": e.id, "sender": e.sender, "subject": e.subject, "body": e.body, "timestamp": e.timestamp, "read_on_air": e.read_on_air, } for e in sorted(_listener_emails, key=lambda e: e.timestamp, reverse=True) ] @app.post("/api/email/{email_id}/play-on-air") async def play_email_on_air(email_id: str): em = next((e for e in _listener_emails if e.id == email_id), None) if not em: raise HTTPException(status_code=404, detail="Email not found") # Extract display name, fall back to just "a listener" sender_name = em.sender.split("<")[0].strip().strip('"') if "<" in em.sender else "a listener" intro = f"This email is from {sender_name}. Subject: {em.subject}." full_text = f"{intro}\n\n{em.body}" async def _generate_and_play(): try: audio_bytes = await generate_speech(full_text, "Alex", phone_quality="none", apply_filter=False) audio_service.play_caller_audio(audio_bytes, 24000) except Exception as exc: print(f"[Email] TTS playback error: {exc}") asyncio.create_task(_generate_and_play()) em.read_on_air = True _save_emails() return {"status": "playing"} @app.delete("/api/email/{email_id}") async def delete_email(email_id: str): em = next((e for e in _listener_emails if e.id == email_id), None) if not em: raise HTTPException(status_code=404, detail="Email not found") _listener_emails.remove(em) _save_emails() return {"status": "deleted"} async def _signalwire_end_call(call_sid: str): """End a phone call via SignalWire REST API""" if not call_sid or not settings.signalwire_space: return try: url = f"https://{settings.signalwire_space}/api/laml/2010-04-01/Accounts/{settings.signalwire_project_id}/Calls/{call_sid}" async with httpx.AsyncClient(timeout=10.0) as client: response = await client.post( url, data={"Status": "completed"}, auth=(settings.signalwire_project_id, settings.signalwire_token), ) print(f"[SignalWire] End call {call_sid}: {response.status_code}") except Exception as e: print(f"[SignalWire] Failed to end call {call_sid}: {e}") # --- Request Models --- class ChatRequest(BaseModel): text: str class TTSRequest(BaseModel): text: str voice_id: str phone_filter: bool = True class AudioDeviceSettings(BaseModel): input_device: Optional[int] = None input_channel: Optional[int] = None output_device: Optional[int] = None caller_channel: Optional[int] = None devon_channel: Optional[int] = None live_caller_channel: Optional[int] = None music_channel: Optional[int] = None sfx_channel: Optional[int] = None ad_channel: Optional[int] = None ident_channel: Optional[int] = None monitor_device: Optional[int] = None monitor_channel: Optional[int] = None phone_filter: Optional[bool] = None class MusicRequest(BaseModel): track: str action: str # "play", "stop", "volume" volume: Optional[float] = None class SFXRequest(BaseModel): sound: str # --- Audio Device Endpoints --- @app.get("/api/audio/devices") async def list_audio_devices(): """List all available audio devices""" return {"devices": audio_service.list_devices()} @app.get("/api/audio/settings") async def get_audio_settings(): """Get current audio device configuration""" return audio_service.get_device_settings() @app.post("/api/audio/settings") async def set_audio_settings(settings: AudioDeviceSettings): """Configure audio devices and channels""" audio_service.set_devices( input_device=settings.input_device, input_channel=settings.input_channel, output_device=settings.output_device, caller_channel=settings.caller_channel, devon_channel=settings.devon_channel, live_caller_channel=settings.live_caller_channel, music_channel=settings.music_channel, sfx_channel=settings.sfx_channel, ad_channel=settings.ad_channel, ident_channel=settings.ident_channel, monitor_device=settings.monitor_device, monitor_channel=settings.monitor_channel, phone_filter=settings.phone_filter ) return audio_service.get_device_settings() # --- Recording Endpoints --- @app.post("/api/record/start") async def start_recording(): """Start recording from configured input device""" if audio_service.input_device is None: raise HTTPException(400, "No input device configured. Set one in /api/audio/settings") success = audio_service.start_recording() if not success: raise HTTPException(400, "Failed to start recording (already recording?)") return {"status": "recording"} def _get_all_caller_names() -> list[str]: """Get all current caller names (from backgrounds or base assignments).""" names = [] for key in CALLER_BASES: bg = session.caller_backgrounds.get(key) if isinstance(bg, dict) and bg.get("name"): names.append(bg["name"]) elif "name" in CALLER_BASES[key]: names.append(CALLER_BASES[key]["name"]) # Always include Devon (the intern) names.append("Devon") return names def _fix_caller_names(text: str, names: list[str]) -> str: """Fix Whisper misspellings of caller names using fuzzy matching. Compares each word against known names — if within edit distance 2 and the word isn't a common English word, replace it.""" if not names or not text: return text # Build lookup: lowercase name -> original name name_map = {n.lower(): n for n in names if n} if not name_map: return text # Common short words that happen to be close to names — never replace these _common_words = { "the", "and", "but", "for", "not", "you", "all", "can", "had", "her", "was", "one", "our", "out", "are", "has", "his", "how", "its", "may", "new", "now", "old", "see", "way", "who", "did", "get", "got", "him", "let", "say", "she", "too", "use", "been", "call", "come", "each", "from", "have", "just", "know", "like", "long", "look", "make", "many", "much", "over", "said", "some", "take", "tell", "than", "that", "them", "then", "they", "this", "time", "very", "want", "well", "went", "were", "what", "when", "will", "with", "your", "been", "yeah", "okay", "sure", "right", "about", "think", "really", "gonna", "gotta", "would", "could", "should", "never", "still", "here", "there", "where", "being", "doing", "going", "having", "saying", "man", "hey", "yes", "no", } def _edit_distance(a: str, b: str) -> int: """Levenshtein distance between two strings.""" if len(a) < len(b): return _edit_distance(b, a) if len(b) == 0: return len(a) prev = list(range(len(b) + 1)) for i, ca in enumerate(a): curr = [i + 1] for j, cb in enumerate(b): cost = 0 if ca == cb else 1 curr.append(min(curr[j] + 1, prev[j + 1] + 1, prev[j] + cost)) prev = curr return prev[len(b)] words = text.split() changed = False for i, word in enumerate(words): # Strip punctuation for matching but preserve it stripped = word.strip(".,!?;:\"'—-") if not stripped or len(stripped) < 3: continue low = stripped.lower() if low in _common_words: continue # Exact match (already correct) if low in name_map: # Fix capitalization if needed correct = name_map[low] if stripped != correct: words[i] = word.replace(stripped, correct) changed = True continue # Fuzzy match against all names — conservative to avoid mangling real words for name_low, name_orig in name_map.items(): # No fuzzy matching for very short names (3 chars) — too many false positives # e.g. "dog" → "Dot", "cat" → "Cal" if len(name_low) <= 3: continue if abs(len(low) - len(name_low)) > 1: continue dist = _edit_distance(low, name_low) # Distance 1 only, and require first letter match to avoid wild substitutions if dist == 1 and low[0] == name_low[0]: print(f"[NameFix] Fuzzy: '{stripped}' -> '{name_orig}' (dist={dist})") words[i] = word.replace(stripped, name_orig) changed = True break if changed: result = " ".join(words) if result != text: print(f"[NameFix] '{text}' -> '{result}'") return result return text @app.post("/api/record/stop") async def stop_recording(): """Stop recording and transcribe""" audio_bytes = audio_service.stop_recording() if len(audio_bytes) < 100: return {"text": "", "status": "no_audio"} # Context hint for Whisper — basic show context only, NO caller names. # Names were over-biasing Whisper (e.g. "bother" → "Luthor"). # Post-transcription fuzzy matching (_fix_caller_names) handles name correction. context_hint = "Luke at the Roost, a late-night radio call-in show." caller_names = _get_all_caller_names() # Transcribe the recorded audio (16kHz raw PCM from audio service) text = await transcribe_audio(audio_bytes, source_sample_rate=16000, context_hint=context_hint) # Post-transcription: fix Whisper misspellings of caller names if text and caller_names: text = _fix_caller_names(text, caller_names) return {"text": text, "status": "transcribed"} # --- Caller Endpoints --- @app.get("/api/callers") 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": display_name, "returning": v.get("returning", False), "avatar_url": f"/api/avatar/{display_name}", } if isinstance(bg, dict): details = bg.get("specific_details") or [] caller_info["identity"] = bg.get("identity", "") caller_info["situation"] = bg.get("situation", "") caller_info["signature"] = details[0] if details else "" caller_info["secret_want"] = bg.get("secret_want", "") caller_info["voice"] = bg.get("voice", "") callers.append(caller_info) return { "callers": callers, "current": session.current_caller_key, "session_id": session.id } @app.get("/api/regulars") async def get_regulars(): """Get list of regular callers""" return {"regulars": regular_caller_service.get_regulars()} @app.post("/api/session/reset") async def reset_session(): """Reset session - all callers get fresh backgrounds. Uses pre-warmed batch if available (instant); otherwise generates fresh.""" session.reset() _chat_updates.clear() await session.populate_backgrounds() return {"status": "reset", "session_id": session.id} def _maybe_generate_callback() -> dict | None: """After 6+ calls, 15% chance to bring back a previous caller with a callback. Returns a callback info dict or None.""" if len(session.call_history) < 6: return None if random.random() > 0.15: return None # Pick a previous AI caller with a good summary ai_calls = [r for r in session.call_history if r.caller_type == "ai" and len(r.summary) > 20] if not ai_calls: return None target = random.choice(ai_calls) callback_reason = random.choice([ f"called back because something changed since they last called about: {target.summary}", f"forgot to mention something important when they called earlier about: {target.summary}", f"heard a later caller and it reminded them of their own situation: {target.summary}", f"the situation from their earlier call has gotten worse: {target.summary}", f"good news — the thing they called about earlier actually worked out: {target.summary}", ]) print(f"[Callback] Generating callback for {target.caller_name}: {callback_reason[:80]}...") return { "caller_name": target.caller_name, "original_summary": target.summary, "callback_reason": callback_reason, } @app.post("/api/call/{caller_key}") async def start_call(caller_key: str): """Start a call with a caller""" global _session_epoch if caller_key not in CALLER_BASES: raise HTTPException(404, "Caller not found") # Guard against double-click or rapid switching if session.current_caller_key == caller_key: return {"status": "already_on_call", "caller_key": caller_key} if session.current_caller_key is not None: # Already on a different call — hang up first audio_service.stop_caller_audio() session.end_call() _session_epoch += 1 audio_service.stop_caller_audio() session.start_call(caller_key) # Check for callback opportunity — only for non-returning callers # Returning callers already have their own PREVIOUS CALLS context base = CALLER_BASES[caller_key] if not base.get("returning"): callback = _maybe_generate_callback() if callback: existing_bg = session.caller_backgrounds.get(caller_key) if isinstance(existing_bg, dict): callback_ctx = f"\n\nPREVIOUS CALLS:\n- (earlier tonight) {callback['original_summary']}\nYou're calling back with an update — {callback['callback_reason']}. Reference your earlier call naturally." existing_bg["situation"] = existing_bg.get("situation", "") + callback_ctx print(f"[Callback] Injected callback context for {base.get('name', caller_key)}") caller = session.caller # This generates the background if needed # Enrich with news/weather in background — don't block call pickup if caller_key in session.caller_backgrounds: asyncio.create_task(_enrich_background_async(caller_key)) # Extract slim background for UI info panel bg = session.caller_backgrounds.get(caller_key) caller_info = {} if isinstance(bg, dict): details = bg.get("specific_details") or [] caller_info = { "identity": bg.get("identity", ""), "situation": bg.get("situation", ""), "signature": details[0] if details else "", "secret_want": bg.get("secret_want", ""), } # Start intern monitoring if enabled if session.intern_monitoring and not intern_service.monitoring: async def _on_intern_suggestion(text, sources): broadcast_event("intern_suggestion", {"text": text, "sources": sources}) intern_service.start_monitoring( get_conversation=lambda: session.conversation, on_suggestion=_on_intern_suggestion, get_caller_active=lambda: session.caller is not None, ) return { "status": "connected", "caller": caller["name"], "background": caller["vibe"], "caller_info": {**caller_info, "avatar_url": f"/api/avatar/{caller['name']}"}, } async def _enrich_background_async(caller_key: str): """Enrich caller background with news/weather without blocking the call""" try: bg = session.caller_backgrounds.get(caller_key) if not isinstance(bg, dict): return enriched = await enrich_caller_background(bg.get("situation", "")) bg["situation"] = enriched except Exception as e: print(f"[Research] Background enrichment failed: {e}") @app.post("/api/hangup") async def hangup(): """Hang up current call""" global _session_epoch, _auto_respond_pending _session_epoch += 1 # Stop any playing caller audio immediately audio_service.stop_caller_audio() # Cancel any pending auto-respond if _auto_respond_pending and not _auto_respond_pending.done(): _auto_respond_pending.cancel() _auto_respond_pending = None _auto_respond_buffer.clear() if session._research_task and not session._research_task.done(): session._research_task.cancel() session._research_task = None # Stop intern monitoring between calls intern_service.stop_monitoring() caller_name = session.caller["name"] if session.caller else None caller_key = session.current_caller_key conversation_snapshot = list(session.conversation) call_started = getattr(session, '_call_started_at', 0.0) was_caller_hangup = session._caller_hangup session._wrapping_up = False session._wrapup_exchanges = 0 session.end_call() # Play hangup sound in background so response returns immediately hangup_sound = settings.sounds_dir / "hangup.wav" if hangup_sound.exists(): threading.Thread(target=audio_service.play_sfx, args=(str(hangup_sound),), daemon=True).start() # Generate summary for AI caller in background if caller_name and conversation_snapshot: asyncio.create_task(_summarize_ai_call(caller_key, caller_name, conversation_snapshot, call_started, was_caller_hangup)) return {"status": "disconnected", "caller": caller_name} @app.post("/api/wrap-up") async def wrap_up(): """Signal the current caller to wrap up gracefully""" if not session.caller: raise HTTPException(400, "No active call") session._wrapping_up = True session._wrapup_exchanges = 0 print(f"[Wrap-up] Initiated for {session.caller['name']}") return {"status": "wrapping_up"} async def _summarize_ai_call(caller_key: str, caller_name: str, conversation: list[dict], started_at: float, caller_hangup: bool = False): """Background task: summarize AI caller conversation and store in history""" ended_at = time.time() summary = "" if conversation: transcript_text = "\n".join( f"{msg['role']}: {msg['content']}" for msg in conversation ) try: summary = await llm_service.generate( messages=[{"role": "user", "content": f"Summarize this radio show call in 1-2 sentences:\n{transcript_text}"}], system_prompt="You summarize radio show conversations concisely. Focus on what the caller talked about and any emotional moments.", category="call_summary", caller_name=caller_name, ) except Exception as e: print(f"[AI Summary] Failed to generate summary: {e}") summary = f"{caller_name} called in." # Populate from slim caller background dict bg = session.caller_backgrounds.get(caller_key) or {} comm_style = bg.get("emotional_register", "") if isinstance(bg, dict) else "" sit_summary = bg.get("situation", "") if isinstance(bg, dict) else "" key_dets = list(bg.get("specific_details") or []) if isinstance(bg, dict) else [] quality_signals = _assess_call_quality( conversation, caller_hangup=caller_hangup, ) session.call_quality_signals.append(quality_signals) session.call_history.append(CallRecord( caller_type="ai", caller_name=caller_name, summary=summary, transcript=conversation, started_at=started_at, ended_at=ended_at, quality_signals=quality_signals, situation_summary=sit_summary, communication_style=comm_style, key_details=key_dets, )) print(f"[AI Summary] {caller_name} call summarized: {summary[:80]}...") print(f"[Quality] {caller_name}: exchanges={quality_signals['exchange_count']} avg_len={quality_signals['avg_response_length']:.0f}c host_engagement={quality_signals['host_engagement']} caller_depth={quality_signals['caller_depth']} natural_end={quality_signals['natural_ending']}") # Returning caller promotion/update logic try: base = CALLER_BASES.get(caller_key) if caller_key else None if base and summary: if base.get("returning") and base.get("regular_id"): # Update existing regular's call history regular_caller_service.update_after_call(base["regular_id"], summary) elif len(conversation) >= 8 and random.random() < 0.05: # 5% chance to promote first-timer with 8+ messages bg = session.caller_backgrounds.get(caller_key) or {} if isinstance(bg, dict): traits = list(bg.get("specific_details") or [])[:4] promo_job = bg.get("identity", "") or "" promo_location = bg.get("location") or "unknown" promo_age = bg.get("age") or random.randint(*base.get("age_range", (30, 50))) promo_gender = base.get("gender", "male") structured_bg = dict(bg) avatar_path = avatar_service.get_path(caller_name) regular_caller_service.add_regular( name=caller_name, gender=promo_gender, age=promo_age, job=promo_job, location=promo_location, personality_traits=traits, first_call_summary=summary, voice=base.get("voice"), stable_seeds={}, structured_background=structured_bg, avatar=avatar_path.name if avatar_path else None, ) except Exception as e: print(f"[Regulars] Promotion logic error: {e}") # Detect relationships: if this caller mentioned another regular by name _detect_caller_relationships(caller_key, caller_name, conversation, summary) _save_checkpoint() def _detect_caller_relationships(caller_key: str, caller_name: str, conversation: list[dict], summary: str): """Scan conversation for mentions of other regular callers and store relationships.""" try: base = CALLER_BASES.get(caller_key) if not base or not base.get("regular_id"): return # Only track relationships for regulars regulars = regular_caller_service.get_regulars() regular_names = {r["name"]: r["id"] for r in regulars if r["name"] != caller_name} if not regular_names: return # Build full text from caller's messages + summary caller_text = summary + " " + " ".join( m["content"] for m in conversation if m.get("role") == "assistant" ) caller_text_lower = caller_text.lower() for other_name in regular_names: if other_name.lower() in caller_text_lower: # Determine relationship type from context rel_type = "mentioned" # Simple sentiment check name_idx = caller_text_lower.index(other_name.lower()) context_window = caller_text_lower[max(0, name_idx - 80):name_idx + 80] negative = any(w in context_window for w in ["wrong", "disagree", "annoying", "hate", "idiot", "crazy", "ridiculous"]) positive = any(w in context_window for w in ["agree", "right", "love", "friend", "respect", "relate", "same"]) if negative: rel_type = "rival" elif positive: rel_type = "ally" context_snippet = caller_text[max(0, name_idx - 40):name_idx + 60].strip() regular_caller_service.add_relationship( base["regular_id"], other_name, rel_type, f"Referenced during call: ...{context_snippet}..." ) print(f"[Relationships] Detected: {caller_name} → {other_name} ({rel_type})") except Exception as e: print(f"[Relationships] Detection error: {e}") # --- Chat & TTS Endpoints --- import re def _pick_response_budget(wrapping_up: bool = False) -> tuple[int, int]: """Pick a random max_tokens and sentence cap for response variety. Returns (max_tokens, max_sentences). Keeps responses conversational but gives room for real answers. Token budget is intentionally generous to avoid mid-sentence cutoffs — the sentence cap controls actual length.""" if wrapping_up: return 200, 2 # Default distribution — give callers room to tell their story roll = random.random() if roll < 0.10: return 600, 6 # 10% — quick response elif roll < 0.35: return 700, 7 # 25% — normal conversation elif roll < 0.65: return 800, 8 # 30% — room to breathe else: return 900, 10 # 35% — telling a story or riffing MIN_RESPONSE_WORDS = 80 # Retry if response is shorter than this async def _retry_if_too_short(response: str, llm_service, messages: list, system_prompt: str, max_tokens: int, caller_name: str, model_override=None, wrapping_up: bool = False) -> str: """Retry once if caller response is too short (some models produce terse output).""" if wrapping_up or not response or "[HANGUP]" in response: return response word_count = len(response.split()) if word_count >= MIN_RESPONSE_WORDS: return response print(f"[Chat] Response too short ({word_count} words), retrying...") retry = await llm_service.generate( messages=messages, system_prompt=system_prompt, max_tokens=max_tokens, category="caller_dialog", caller_name=caller_name, model_override=model_override, ) if retry and len(retry.split()) > word_count: print(f"[Chat] Retry produced {len(retry.split())} words (was {word_count})") return retry print(f"[Chat] Retry no better, keeping original") return response _REPETITION_STOPWORDS = { "i", "me", "my", "you", "your", "he", "she", "it", "we", "they", "a", "an", "the", "is", "are", "was", "were", "be", "been", "being", "have", "has", "had", "do", "does", "did", "will", "would", "could", "should", "can", "may", "might", "shall", "to", "of", "in", "for", "on", "with", "at", "by", "from", "and", "or", "but", "not", "no", "that", "this", "what", "which", "who", "how", "if", "so", "just", "than", "then", "about", "up", "out", "all", "like", "got", "get", } def _has_repetition(response: str, conversation: list, threshold: int = 3) -> bool: """Check if the response contains repeated 3+ word n-grams from recent conversation.""" # Collect last 6 assistant messages recent_assistant = [ msg["content"] for msg in conversation if msg.get("role") == "assistant" and msg.get("content") ][-6:] prior_text = " ".join(recent_assistant) # Extract 3-word n-grams from response and prior text combined def get_ngrams(text): words = text.lower().split() return [" ".join(words[i:i+3]) for i in range(len(words) - 2)] response_ngrams = get_ngrams(response) if not response_ngrams: return False all_ngrams = get_ngrams(prior_text) + response_ngrams # Count occurrences counts: dict[str, int] = {} for ng in all_ngrams: counts[ng] = counts.get(ng, 0) + 1 # Check if any response n-gram hits threshold (skip all-stopword n-grams) response_set = set(response_ngrams) for ng in response_set: if counts.get(ng, 0) >= threshold: words = ng.split() if not all(w in _REPETITION_STOPWORDS for w in words): return True return False def _trim_to_sentences(text: str, max_sentences: int) -> str: """Hard-trim response to at most max_sentences sentences.""" if not text: return text # Split on sentence-ending punctuation, keeping the delimiter. # Negative lookbehind avoids splitting on common abbreviations (Mr. Mrs. Ms. Dr. St. etc.) parts = re.split(r'(? str: """If text was cut off mid-sentence, trim to the last complete sentence.""" text = text.strip() if not text: return text # Already ends with sentence-ending punctuation — good if text[-1] in '.!?': return text # Cut off mid-sentence — find the last complete sentence for i in range(len(text) - 1, -1, -1): if text[i] in '.!?': return text[:i + 1] # No punctuation at all — just add a period return text.rstrip(',;:— -') + '.' _DIGIT_WORDS = ["zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine"] _TENS_WORDS = { 10: "ten", 11: "eleven", 12: "twelve", 13: "thirteen", 14: "fourteen", 15: "fifteen", 16: "sixteen", 17: "seventeen", 18: "eighteen", 19: "nineteen", 20: "twenty", 30: "thirty", 40: "forty", 50: "fifty", 60: "sixty", 70: "seventy", 80: "eighty", 90: "ninety", } def _number_to_spoken(n: int) -> str: """Convert a number (0-9999) to natural spoken English.""" if n < 10: return _DIGIT_WORDS[n] if n in _TENS_WORDS: return _TENS_WORDS[n] if n < 100: tens = (n // 10) * 10 ones = n % 10 return f"{_TENS_WORDS[tens]} {_DIGIT_WORDS[ones]}" if ones else _TENS_WORDS[tens] if n < 1000: hundreds = n // 100 remainder = n % 100 if remainder == 0: return f"{_DIGIT_WORDS[hundreds]} hundred" return f"{_DIGIT_WORDS[hundreds]} hundred {_number_to_spoken(remainder)}" if n < 10000: thousands = n // 1000 remainder = n % 1000 if remainder == 0: return f"{_number_to_spoken(thousands)} thousand" if remainder < 100: return f"{_number_to_spoken(thousands)} thousand {_number_to_spoken(remainder)}" return f"{_number_to_spoken(thousands)} thousand {_number_to_spoken(remainder)}" return str(n) # Numbers that should always be read digit-by-digit _DIGIT_BY_DIGIT = { "911": "nine one one", "411": "four one one", "311": "three one one", "211": "two one one", "511": "five one one", "811": "eight one one", "101": "one oh one", "24/7": "twenty four seven", "401k": "four oh one k", "403b": "four oh three b", "409a": "four oh nine a", "w2": "W two", "w-2": "W two", "1099": "ten ninety nine", "i-10": "I ten", "i-25": "I twenty five", "i-40": "I forty", } # Ordinal numbers → spoken form (covers dates, rankings, common usage) _ORDINALS = { "1st": "first", "2nd": "second", "3rd": "third", "4th": "fourth", "5th": "fifth", "6th": "sixth", "7th": "seventh", "8th": "eighth", "9th": "ninth", "10th": "tenth", "11th": "eleventh", "12th": "twelfth", "13th": "thirteenth", "14th": "fourteenth", "15th": "fifteenth", "16th": "sixteenth", "17th": "seventeenth", "18th": "eighteenth", "19th": "nineteenth", "20th": "twentieth", "21st": "twenty first", "22nd": "twenty second", "23rd": "twenty third", "24th": "twenty fourth", "25th": "twenty fifth", "26th": "twenty sixth", "27th": "twenty seventh", "28th": "twenty eighth", "29th": "twenty ninth", "30th": "thirtieth", "31st": "thirty first", } # Common title/address abbreviations that TTS should expand. # Order matters: context-specific patterns first, then generic. # Use a list of tuples so patterns aren't deduplicated by dict keys. _COMMON_ABBREVIATIONS = [ # Titles (Dr. → Doctor handled separately with case-sensitive lookahead) (r'\bMr\.', 'Mister'), (r'\bMrs\.', 'Missus'), (r'\bMs\.', 'Miss'), (r'\bJr\.', 'Junior'), (r'\bSr\.', 'Senior'), # St. → Saint before known proper names, Street otherwise (r'\bSt\.\s+(?=Patrick|Louis|George|Mary|John|Joseph|Paul|Peter|Thomas|Andrew|Francis|James|Lawrence|Augustine|Anthony|Bernard|Michael|Nicholas|David|Stephen|Charles|Claire|Anne|Elmo|Jude)', 'Saint '), (r'\bSt\.', 'Street'), (r'\bAve\.', 'Avenue'), (r'\bBlvd\.', 'Boulevard'), (r'\bRd\.', 'Road'), (r'\bDr\.', 'Drive'), (r'\bLn\.', 'Lane'), (r'\bCt\.', 'Court'), # General (r'\betc\.', 'etcetera'), (r'\bapprox\.', 'approximately'), (r'\bft\.', 'feet'), (r'\bmi\.', 'miles'), (r'\blb\.', 'pound'), (r'\blbs\.', 'pounds'), (r'\boz\.', 'ounces'), (r'\bmin\.', 'minutes'), (r'\bhr\.', 'hour'), (r'\bhrs\.', 'hours'), (r'\bw/o\b', 'without'), (r'\bw/', 'with '), ] def _expand_numbers_for_tts(text: str) -> str: """Expand numbers that TTS engines commonly mispronounce.""" # Fixed substitutions (case-insensitive) for pattern, replacement in _DIGIT_BY_DIGIT.items(): text = re.sub(re.escape(pattern), replacement, text, flags=re.IGNORECASE) # Vehicle models: F-350 → F three fifty, RAM-2500 → RAM twenty five hundred def _model_number(m): letter_part = m.group(1) num = int(m.group(2)) return f"{letter_part} {_number_to_spoken(num)}" text = re.sub(r'\b([A-Z]{1,3})[-.]?(\d{2,4})\b', _model_number, text) # Calibers: .308 → three oh eight, .223 → two twenty three, .22 → twenty two, etc. def _caliber_to_words(m): cal = m.group(1) caliber_map = { "308": "three oh eight", "223": "two twenty three", "556": "five fifty six", "762": "seven sixty two", "300": "three hundred", "338": "three thirty eight", "270": "two seventy", "243": "two forty three", "357": "three fifty seven", "380": "three eighty", "45": "forty five", "44": "forty four", "38": "thirty eight", "22": "twenty two", "50": "fifty", "9": "nine millimeter", "40": "forty", "410": "four ten", "12": "twelve gauge", "20": "twenty gauge", } return caliber_map.get(cal, " ".join(_DIGIT_WORDS[int(d)] for d in cal)) text = re.sub(r'(? 2000 else "two thousand" if 2010 <= year <= 2099: return f"twenty {_number_to_spoken(year - 2000)}" if 1900 <= year <= 1999: century = year // 100 remainder = year % 100 if remainder == 0: return f"{_number_to_spoken(century)} hundred" return f"{_number_to_spoken(century)} {_number_to_spoken(remainder)}" return str(year) text = re.sub(r'\b((?:19|20)\d{2})\b', _year_to_words, text) # Standalone numbers 2-4 digits (not already handled) — natural spoken form # Only matches numbers surrounded by word boundaries, not inside other patterns def _general_number(m): num = int(m.group(0)) if num < 10: return m.group(0) # single digits are fine return _number_to_spoken(num) text = re.sub(r'(? str: """Handle ALL CAPS words in one pass: - Spoken acronyms (NASA, FEMA): leave as-is - Short words (2-3 letters) that are common English: lowercase (emphasis) - Short words (2-3 letters) that are NOT common English: spell out (acronym) - Long words (4+ letters): lowercase (emphasis) """ def _replace(m): word = m.group(0) upper = word.upper() # Spoken acronyms — leave alone if upper in _SPOKEN_ACRONYMS: return word length = len(word) if length <= 3: # Short word: if it's a common English word, it's emphasis → lowercase # Otherwise it's an acronym → spell out if upper in _EMPHASIS_SHORT_WORDS: return word.lower() else: return " ".join(word.upper()) else: # 4+ letters: almost always emphasis (REALLY, NEVER, ABSOLUTELY) return word.lower() return re.sub(r'\b[A-Z]{2,}\b', _replace, text) def _apply_pronunciation_fixes(text: str) -> str: """Apply known pronunciation fixes for words TTS engines botch.""" for word, fix in _PRONUNCIATION_FIXES.items(): text = re.sub(r'\b' + re.escape(word) + r'\b', fix, text, flags=re.IGNORECASE) return text def clean_for_tts(text: str, formal: bool = True) -> str: """Strip out non-speakable content and fix phonetic spellings for TTS. When formal=False, keeps colloquialisms (gonna, kinda, etc.) for natural-sounding callers.""" # Remove stage-direction parentheticals: (laughs), (pausing), (looking away), etc. # Only match parens that start with a known action word — avoids eating real dialog # like "I (get this look) that" → "I that" _action_start = r'(?:laughs?|laughing|sighs?|sighing|pauses?|pausing|smiles?|smiling|chuckles?|chuckling|grins?|grinning|nods?|nodding|shrugs?|shrugging|frowns?|frowning|looks?|looking|clears?|clearing|takes?|taking|leans?|leaning|shakes?|shaking|closes?|closing|opens?|opening|whispers?|whispering|mumbles?|mumbling|trails?|trailing|voice|silence|beat|quiet|long pause|deep breath|softly|nervously|quietly|crying|sobbing|sniffling|exhales?|exhaling|inhales?|inhaling)' text = re.sub(r'\s*\((?=' + _action_start + r')[^)]{1,40}\)\s*', ' ', text, flags=re.IGNORECASE) # Remove stage-direction asterisks: *laughs*, *sighs deeply*, etc. # Only match short action-like content, not emphasis like *really* or *the* important thing text = re.sub(r'\s*\*(?=' + _action_start + r')[^*]{1,40}\*\s*', ' ', text, flags=re.IGNORECASE) # Remove content in brackets: [laughs], [pause], etc. (only Bark uses these) text = re.sub(r'\s*\[(?=' + _action_start + r')[^\]]{1,40}\]\s*', ' ', text, flags=re.IGNORECASE) # Remove content in angle brackets: , , etc. text = re.sub(r'\s*<(?=' + _action_start + r')[^>]{1,40}>\s*', ' ', text, flags=re.IGNORECASE) # Remove "He/She sighs" style stage directions (NOT "I" — too aggressive, eats real dialog) text = re.sub(r'\b(He|She|They)\s+(sighs?|laughs?|pauses?|smiles?|chuckles?|grins?|nods?|shrugs?|frowns?)\s*(heavily|softly|deeply|quietly|loudly|nervously|sadly|a little|for a moment)?[.,]?\s*', '', text, flags=re.IGNORECASE) # Remove standalone stage direction words only if they look like directions (with adverbs) text = re.sub(r'\b(sighs?|laughs?|pauses?|chuckles?)\s+(heavily|softly|deeply|quietly|loudly|nervously|sadly)\b[.,]?\s*', '', text, flags=re.IGNORECASE) # Catch-all safety net: any remaining short parenthetical is almost certainly a stage # direction that wasn't caught by the specific patterns above (e.g. adjective-first # patterns like "(nervous laugh)" or "(a long beat)"). Nothing in parens should be # read aloud on air. text = re.sub(r'\s*\([^)]{1,40}\)\s*', ' ', text) # Catch-all for multi-word asterisk content — single-word *emphasis* is fine, # but multi-word like *sighs deeply* or *nervous laughter* is a stage direction text = re.sub(r'\s*\*\w+\s[^*]{1,30}\*\s*', ' ', text) # Remove quotes around the response if LLM wrapped it text = re.sub(r'^["\']|["\']$', '', text.strip()) # --- Punctuation normalization for natural prosody --- # Note: em dashes (—) and ellipses (...) are preserved here — Inworld handles them # with SSML tags in _prepare_text_for_inworld(), and other engines handle # them natively or via their own preprocessing. # Double hyphen → em dash (normalize before TTS engines handle it) text = re.sub(r'\s*--\s*', ' — ', text) # Unicode ellipsis → three dots (normalize for consistent handling downstream) text = re.sub(r'…', '...', text) # Semicolons → period (TTS doesn't differentiate semicolon from comma well) text = re.sub(r';', '.', text) # --- Symbols to speakable text --- # Ampersand text = re.sub(r'\s*&\s*', ' and ', text) # Hash/number sign (before a number = "number", standalone = skip) text = re.sub(r'#(\d)', r'number \1', text) # Plus sign between words text = re.sub(r'\s*\+\s*', ' plus ', text) # Equals sign text = re.sub(r'\s*=\s*', ' equals ', text) # At sign (in non-email context) text = re.sub(r'(? tags in # _prepare_text_for_inworld(), Kokoro uses comma insertion in preprocess_text_for_kokoro() # --- Final cleanup --- # Clean up extra whitespace text = re.sub(r'\s+', ' ', text) # Fix spaces before punctuation text = re.sub(r'\s+([.,!?])', r'\1', text) # Fix double punctuation from earlier transformations (preserve ellipsis "...") text = re.sub(r'([,!?])\s*\1+', r'\1', text) text = re.sub(r'(? _CHAT_UPDATES_MAX: del _chat_updates[:_CHAT_UPDATES_MAX // 2] def broadcast_event(event_type: str, data: dict = None): """Add a system event to the update queue for frontend polling""" entry = {"type": event_type, "id": len(_chat_updates)} if data: entry.update(data) _chat_updates.append(entry) @app.get("/api/conversation/updates") async def get_conversation_updates(since: int = 0): """Get new chat/event messages since a given index""" return { "messages": _chat_updates[since:], "wrapping_up": session._wrapping_up, "intern_suggestion": intern_service.get_pending_suggestion(), } def _dynamic_context_window() -> int: """Return context window size based on conversation length. Scales up for longer calls so the caller doesn't forget what they already shared and circle back to the same details.""" n = len(session.conversation) if n <= 10: return 10 elif n <= 16: return 15 elif n <= 24: return 24 elif n <= 32: return 32 else: return 40 def _normalize_messages_for_llm(messages: list[dict]) -> list[dict]: """Convert custom roles (real_caller:X, ai_caller:X, intern:X, voicemail:X) to standard LLM roles""" normalized = [] for msg in messages: role = msg["role"] content = msg["content"] if role.startswith("real_caller:"): caller_label = role.split(":", 1)[1] normalized.append({"role": "user", "content": f"[Real caller {caller_label}]: {content}"}) elif role.startswith("ai_caller:"): normalized.append({"role": "assistant", "content": content}) elif role.startswith("intern:"): intern_name = role.split(":", 1)[1] normalized.append({"role": "user", "content": f"[Intern {intern_name}, in the studio]: {content}"}) elif role.startswith("voicemail:"): vm_phone = role.split(":", 1)[1] normalized.append({"role": "user", "content": f"[Voicemail from {vm_phone}, played on air]: {content}"}) elif role == "host" or role == "user": normalized.append({"role": "user", "content": f"[Host Luke]: {content}"}) else: normalized.append(msg) return normalized _DEVON_PATTERN = r"\b(devon|devin|deven|devyn|devan|devlin|devvon)\b" def _is_addressed_to_devon(text: str) -> bool: """Check if the host is talking to Devon based on first few words. Handles common voice-to-text misspellings.""" t = text.strip().lower() if re.match(rf"^(hey |yo |ok |okay )?{_DEVON_PATTERN}", t): return True return False @app.post("/api/chat") async def chat(request: ChatRequest): """Chat with current caller""" if not session.caller: raise HTTPException(400, "No active call") # Check if host is talking to Devon instead of the caller if _is_addressed_to_devon(request.text): # Strip Devon prefix and route to intern stripped = re.sub(rf"^(?:hey |yo |ok |okay )?{_DEVON_PATTERN}[,:\s]*", "", request.text.strip(), flags=re.IGNORECASE).strip() if not stripped: stripped = "what's up?" # Add host message to conversation so caller hears it happened session.add_message("user", request.text) result = await intern_service.ask( question=stripped, conversation_context=session.conversation, caller_active=True, ) devon_text = result.get("text", "") if devon_text: session.add_message(f"intern:{intern_service.name}", devon_text) broadcast_event("intern_response", {"text": devon_text, "intern": intern_service.name}) asyncio.create_task(_play_intern_audio(devon_text)) return { "routed_to": "devon", "text": devon_text or "Uh... give me a sec.", "sources": result.get("sources", []), } epoch = _session_epoch session.add_message("user", request.text) # session._research_task = asyncio.create_task(_background_research(request.text)) try: async with _ai_response_lock: if _session_epoch != epoch: raise HTTPException(409, "Call ended while waiting") # Stop any playing caller audio so responses don't overlap audio_service.stop_caller_audio() show_history = session.get_show_history() is_wrapping = session._wrapping_up mood = detect_host_mood(session.conversation, wrapping_up=is_wrapping) # Track wrap-up exchanges and force hangup after 2 if is_wrapping: session._wrapup_exchanges += 1 if session._wrapup_exchanges > 2: mood += "\nSay goodbye NOW and end with [HANGUP]\n" slim_caller = session.caller_backgrounds.get(session.current_caller_key, {}) system_prompt = get_caller_prompt(slim_caller, theme=session.show_theme) max_tokens, max_sentences = _pick_response_budget(wrapping_up=is_wrapping) messages = _normalize_messages_for_llm(session.conversation[-_dynamic_context_window():]) _caller_name = session.caller.get("name", "") if session.caller else "" _model_override = None # caller_dialog category routes to sonnet-4.6 response = await llm_service.generate( messages=messages, system_prompt=system_prompt, max_tokens=max_tokens, category="caller_dialog", caller_name=_caller_name, model_override=_model_override, ) response = await _retry_if_too_short( response, llm_service, messages, system_prompt, max_tokens, _caller_name, _model_override, wrapping_up=is_wrapping) if not is_wrapping and response and "[HANGUP]" not in response and _has_repetition(response, session.conversation): print(f"[Chat] Repetition detected, retrying with anti-repetition prompt...") retry_messages = messages + [{"role": "user", "content": "You're repeating yourself. Say something NEW — a detail you haven't mentioned, a different angle, or move the story forward. Do not repeat facts you've already stated."}] retry_response = await llm_service.generate( messages=retry_messages, system_prompt=system_prompt, max_tokens=max_tokens, category="caller_dialog", caller_name=_caller_name, model_override=_model_override, ) if retry_response and not _has_repetition(retry_response, session.conversation): print(f"[Chat] Anti-repetition retry succeeded") response = retry_response else: print(f"[Chat] Anti-repetition retry no better, keeping original") except HTTPException: raise except Exception as e: print(f"[Chat] LLM error: {e}") response = "Sorry, I blanked out for a second there. What was that?" # Discard if call changed while we were generating if _session_epoch != epoch: print(f"[Chat] Discarding stale response (epoch {epoch} → {_session_epoch})") raise HTTPException(409, "Call changed during response") print(f"[Chat] Raw LLM ({max_tokens}tok/{max_sentences}s): {response[:100] if response else '(empty)'}...") # Clean response for TTS (remove parenthetical actions, asterisks, etc.) response = clean_for_tts(response, formal=False) response = _trim_to_sentences(response, max_sentences) response = ensure_complete_thought(response) # Detect [HANGUP] sentinel — caller wants to end the call caller_hangup = "[HANGUP]" in response if caller_hangup: response = response.replace("[HANGUP]", "").strip() session._caller_hangup = True print(f"[Chat] Caller hangup detected") print(f"[Chat] Cleaned: {response[:100] if response else '(empty)'}...") # Ensure we have a valid response if not response or not response.strip(): response = "Uh... sorry, what was that?" session.add_message("assistant", response) result = { "text": response, "caller": session.caller["name"], "voice_id": session.caller["voice"] } if caller_hangup: result["hangup"] = True return result @app.post("/api/tts") async def text_to_speech(request: TTSRequest): """Generate and play speech on caller output device (non-blocking)""" if not request.text or not request.text.strip(): raise HTTPException(400, "Text cannot be empty") epoch = _session_epoch try: audio_bytes = await generate_speech( request.text, request.voice_id, "none" ) except Exception as e: print(f"[TTS] Failed: {e}") broadcast_event("ai_done") raise HTTPException(503, f"TTS generation failed: {e}") # Don't play if call changed during TTS generation if _session_epoch != epoch: return {"status": "discarded", "duration": 0} # Stop any existing audio before playing new audio_service.stop_caller_audio() # Play in background thread - returns immediately, can be interrupted by hangup thread = threading.Thread( target=audio_service.play_caller_audio, args=(audio_bytes, 24000), daemon=True ) thread.start() # Also stream to active real callers so they hear the AI if session.active_real_caller: caller_id = session.active_real_caller["caller_id"] asyncio.create_task( caller_service.stream_audio_to_caller(caller_id, audio_bytes, 24000) ) return {"status": "playing", "duration": len(audio_bytes) / 2 / 24000} @app.post("/api/tts/stop") async def stop_tts(): """Stop any playing caller audio""" audio_service.stop_caller_audio() return {"status": "stopped"} # --- Music Endpoints --- GENRE_KEYWORDS = { "rock": "Rock", "funk": "Funk", "funky": "Funk", "hip-hop": "Hip-Hop", "hip hop": "Hip-Hop", "rap": "Hip-Hop", "jazz": "Jazz", "blues": "Blues", "latin": "Latin", "lo-fi": "Lo-Fi", "lofi": "Lo-Fi", "coffee": "Lo-Fi", "radio": "Radio", "valentine": "Ballad", "romantic": "Ballad", "ballad": "Ballad", "irish": "Irish", "ireland": "Irish", "patricks": "Irish", "ambient": "Ambient", "chill": "Chill", "acoustic": "Acoustic", "classical": "Classical", "piano": "Classical", "country": "Country", "western": "Country", "electronic": "Electronic", "synth": "Electronic", } def _detect_genre(name: str) -> str: lower = name.lower() for keyword, genre in GENRE_KEYWORDS.items(): if keyword in lower: return genre return "Other" @app.get("/api/music") async def get_music(): """Get available music tracks, shuffled and tagged with genre""" tracks = [] if settings.music_dir.exists(): for ext in ['*.wav', '*.mp3', '*.flac']: for f in settings.music_dir.glob(ext): tracks.append({ "name": f.stem, "file": f.name, "path": str(f), "genre": _detect_genre(f.stem), }) random.shuffle(tracks) return { "tracks": tracks, "playing": audio_service.is_music_playing() } @app.post("/api/music/play") async def play_music(request: MusicRequest): """Load and play a music track, crossfading if already playing""" track_path = settings.music_dir / request.track if not track_path.exists(): raise HTTPException(404, "Track not found") if audio_service.is_music_playing(): audio_service.crossfade_to(str(track_path)) else: audio_service.load_music(str(track_path)) audio_service.play_music() return {"status": "playing", "track": request.track} @app.post("/api/music/stop") async def stop_music(): """Stop music playback""" audio_service.stop_music() return {"status": "stopped"} @app.post("/api/music/volume") async def set_music_volume(request: MusicRequest): """Set music volume""" if request.volume is not None: audio_service.set_music_volume(request.volume) return {"status": "ok", "volume": request.volume} # --- Sound Effects Endpoints --- SFX_DISPLAY_NAMES = { "airhorn": "📢 Airhorn", "applause": "👏 Applause", "boo": "👎 Boo", "busy": "📞 Busy", "buzzer": "🚫 Buzzer", "car_crash": "💥 Car Crash", "cheer": "✅ Correct", "clock_ticking": "⏰ Clock Ticking", "commercial_jingle": "🎵 Jingle", "crickets": "🦗 Crickets", "ding": "🔔 Ding", "doorbell": "🚪 Doorbell", "drumroll": "🥁 Drumroll", "dun_dun_dun": "😱 Dun Dun Dun", "explosion": "💣 Explosion", "fart": "💨 Fart", "gasp": "😮 Gasp", "glass_shatter": "🪟 Glass Shatter", "hangup": "📵 Hangup", "hold_music": "🎶 Hold Music", "laugh_track": "😂 Laugh Track", "news_stinger": "📰 News Stinger", "phone_ring": "☎️ Phone Ring", "record_scratch": "💿 Record Scratch", "rimshot": "🪘 Rimshot", "sad_trombone": "😢 Sad Trombone", "thunder": "⛈️ Thunder", "victory_fanfare": "🏆 Victory Fanfare", "whoosh": "🌀 Whoosh", "wolf_whistle": "😏 Wolf Whistle", } SFX_PRIORITY = ["sad_trombone", "cheer"] @app.get("/api/sounds") async def get_sounds(): """Get available sound effects""" sounds = [] if settings.sounds_dir.exists(): for f in settings.sounds_dir.glob('*.wav'): sounds.append({ "name": SFX_DISPLAY_NAMES.get(f.stem, f.stem), "file": f.name, "path": str(f) }) priority_set = {p + ".wav" for p in SFX_PRIORITY} priority = [s for p in SFX_PRIORITY for s in sounds if s["file"] == p + ".wav"] rest = sorted([s for s in sounds if s["file"] not in priority_set], key=lambda s: s["name"]) return {"sounds": priority + rest} @app.post("/api/sfx/play") async def play_sfx(request: SFXRequest): """Play a sound effect""" sound_path = settings.sounds_dir / request.sound if not sound_path.exists(): raise HTTPException(404, "Sound not found") audio_service.play_sfx(str(sound_path)) return {"status": "playing", "sound": request.sound} # --- Ads Endpoints --- AD_DISPLAY_NAMES = { "bettermaybe_ad": "Better Maybe", "bunkhousedns_ad": "Bunkhouse DNS", "cryptono_ad": "CryptoNo", "desertgut_ad": "Desert Gut", "enema_ad": "Enema", "jamhospitalityad": "Jam Hospitality", "mealprep_ad": "Meal Prep", "mediocrecpap": "Mediocre CPAP", "pillowforever_ad": "Pillow Forever", "placiboleaf": "Placibo Leaf", "saddlesoft_ad": "Saddle Soft", "sandstone_ad": "Sandstone", "scriptdrift_ad": "Script Drift", "shoespraycoad": "Shoe Spray Co.", "squarehole_ad": "Square Hole", "therapy_ad": "Therapy", "vpnad": "VPN", } @app.get("/api/ads") async def get_ads(): """Get available ad tracks, shuffled""" ad_list = [] if settings.ads_dir.exists(): for ext in ['*.wav', '*.mp3', '*.flac']: for f in settings.ads_dir.glob(ext): ad_list.append({ "name": AD_DISPLAY_NAMES.get(f.stem, f.stem), "file": f.name, "path": str(f) }) random.shuffle(ad_list) return {"ads": ad_list} @app.post("/api/ads/play") async def play_ad(request: MusicRequest): """Play an ad once on the ad channel (ch 11)""" ad_path = settings.ads_dir / request.track if not ad_path.exists(): raise HTTPException(404, "Ad not found") if audio_service._music_playing: audio_service.stop_music(fade_duration=1.0) await asyncio.sleep(1.1) audio_service.play_ad(str(ad_path)) return {"status": "playing", "track": request.track} @app.post("/api/ads/stop") async def stop_ad(): """Stop ad playback""" audio_service.stop_ad() return {"status": "stopped"} # --- Idents Endpoints --- IDENT_DISPLAY_NAMES = {} @app.get("/api/idents") async def get_idents(): """Get available ident tracks, shuffled""" ident_list = [] if settings.idents_dir.exists(): for ext in ['*.wav', '*.mp3', '*.flac']: for f in settings.idents_dir.glob(ext): ident_list.append({ "name": IDENT_DISPLAY_NAMES.get(f.stem, f.stem), "file": f.name, "path": str(f) }) random.shuffle(ident_list) return {"idents": ident_list} @app.post("/api/idents/play") async def play_ident(request: MusicRequest): """Play an ident once on the ad channel (ch 11)""" ident_path = settings.idents_dir / request.track if not ident_path.exists(): raise HTTPException(404, "Ident not found") if audio_service._music_playing: audio_service.stop_music(fade_duration=1.0) await asyncio.sleep(1.1) audio_service.play_ident(str(ident_path)) return {"status": "playing", "track": request.track} @app.post("/api/idents/stop") async def stop_ident(): """Stop ident playback""" audio_service.stop_ident() return {"status": "stopped"} # --- LLM Settings Endpoints --- @app.get("/api/settings") async def get_settings(): """Get LLM settings""" return await llm_service.get_settings_async() @app.post("/api/settings") async def update_settings(data: dict): """Update LLM and TTS settings""" old_tts = settings.tts_provider llm_service.update_settings( provider=data.get("provider"), openrouter_model=data.get("openrouter_model"), ollama_model=data.get("ollama_model"), ollama_host=data.get("ollama_host"), tts_provider=data.get("tts_provider"), category_models=data.get("category_models") ) # Re-randomize voices when TTS provider changes voice system new_tts = settings.tts_provider if new_tts != old_tts: old_is_el = old_tts == "elevenlabs" new_is_el = new_tts == "elevenlabs" if old_is_el != new_is_el: _randomize_callers() print(f"[Settings] TTS changed {old_tts} → {new_tts}, re-randomized voices") return llm_service.get_settings() # --- Show Theme --- @app.get("/api/show-theme") async def get_show_theme(): return {"theme": session.show_theme} @app.post("/api/show-theme") async def set_show_theme(data: dict): theme = data.get("theme", "").strip()[:100] old_theme = session.show_theme session.show_theme = theme if theme: print(f"[Theme] Show theme set: {theme}") elif old_theme: print(f"[Theme] Show theme cleared (was: {old_theme})") # Regenerate backgrounds for unused callers so theme gets baked in. # Awaited (not fire-and-forget) so the response only returns once the # caller list is consistent — the frontend reloads /api/callers after # this resolves and must see the new names, not the stale ones. if theme and theme != old_theme: used_keys = set() if session.current_caller_key: used_keys.add(session.current_caller_key) # Match call_history entries against the slim background names, # not CALLER_BASES randomized fallbacks — those live in a different # namespace and would never match, flagging every slot as unused. for record in session.call_history: for key, bg in session.caller_backgrounds.items(): if isinstance(bg, dict) and bg.get("name") == record.caller_name: used_keys.add(key) break unused_keys = [k for k in CALLER_BASES if k not in used_keys] if unused_keys: print(f"[Theme] Regenerating backgrounds for {len(unused_keys)} unused callers") await _regenerate_backgrounds_for_keys(unused_keys) return {"theme": session.show_theme} # --- Cost Tracking Endpoints --- @app.get("/api/costs") async def get_costs(): """Get live cost summary""" return cost_tracker.get_live_summary() @app.get("/api/costs/report") async def get_cost_report(): """Get full cost report with breakdowns and recommendations""" return cost_tracker.generate_report() # --- Cost Dashboard Endpoints --- @app.get("/api/costs/summary") async def get_cost_summary(period: str = "all"): return cost_db.get_summary(period) @app.get("/api/costs/timeline") async def get_cost_timeline(period: str = "all", group_by: str = "session"): return cost_db.get_timeline(period, group_by) @app.get("/api/costs/models") async def get_cost_models(period: str = "all"): return cost_db.get_models(period) @app.get("/api/costs/categories") async def get_cost_categories(period: str = "all"): return cost_db.get_categories(period) @app.get("/api/costs/sessions") async def get_cost_sessions(period: str = "all"): return cost_db.get_sessions_list(period) @app.get("/api/costs/session/{session_id}") async def get_cost_session_detail(session_id: str): detail = cost_db.get_session_detail(session_id) if not detail: return JSONResponse(status_code=404, content={"error": "Session not found"}) return detail @app.get("/api/costs/expensive") async def get_cost_expensive_calls(period: str = "all", limit: int = 10): return cost_db.get_expensive_calls(period, limit) @app.get("/api/costs/tts") async def get_cost_tts_providers(period: str = "all"): return cost_db.get_tts_providers(period) # --- Caller Screening --- SCREENING_PROMPT = """You are a friendly, brief phone screener for "Luke at the Roost" radio show. Your job: Get the caller's first name and what they want to talk about. That's it. Rules: - Be warm but brief (1-2 sentences per response) - First ask their name, then ask what they want to talk about - After you have both, say something like "Great, sit tight and we'll get you on with Luke!" - Never pretend to be Luke or the host - Keep it casual and conversational - If they're hard to understand, ask them to repeat""" _screening_audio_buffers: dict[str, bytearray] = {} async def _start_screening_greeting(caller_id: str): """Send initial screening greeting to queued caller after brief delay""" await asyncio.sleep(2) # Wait for stream to stabilize ws = caller_service._websockets.get(caller_id) if not ws: return caller_service.start_screening(caller_id) greeting = "Hey there! Thanks for calling Luke at the Roost. What's your name?" caller_service.update_screening(caller_id, screener_text=greeting) try: audio_bytes = await generate_speech(greeting, "Sarah", "none") if audio_bytes: await caller_service.stream_audio_to_caller(caller_id, audio_bytes, 24000) except Exception as e: print(f"[Screening] Greeting TTS failed: {e}") async def _handle_screening_audio(caller_id: str, pcm_data: bytes, sample_rate: int): """Process audio from a queued caller for screening conversation""" state = caller_service.get_screening_state(caller_id) if not state or state["status"] == "complete": return # Skip if TTS is currently streaming to this caller if caller_service.is_streaming_tts(caller_id): return # Transcribe caller speech try: text = await transcribe_audio(pcm_data, source_sample_rate=sample_rate, context_hint="A caller is being screened before going on air.") except Exception as e: print(f"[Screening] Transcription failed: {e}") return if not text or not text.strip(): return print(f"[Screening] Caller {caller_id}: {text}") caller_service.update_screening(caller_id, caller_text=text) # Build conversation for LLM messages = [] for msg in state["conversation"]: role = "assistant" if msg["role"] == "screener" else "user" messages.append({"role": role, "content": msg["content"]}) # Generate screener response try: response = await llm_service.generate( messages=messages, system_prompt=SCREENING_PROMPT, category="screener", ) except Exception as e: print(f"[Screening] LLM failed: {e}") return if not response or not response.strip(): return response = response.strip() print(f"[Screening] Screener → {caller_id}: {response}") caller_service.update_screening(caller_id, screener_text=response) # After 2+ caller responses, try to extract name and topic if state["response_count"] >= 2: try: extract_prompt = f"""From this screening conversation, extract the caller's name and topic. Conversation: {chr(10).join(f'{m["role"]}: {m["content"]}' for m in state["conversation"])} Respond with ONLY JSON: {{"name": "their first name or null", "topic": "brief topic or null"}}""" extract = await llm_service.generate( messages=[{"role": "user", "content": extract_prompt}], system_prompt="You extract structured data from conversations. Respond with only valid JSON.", category="screener", ) json_match = re.search(r'\{[^}]+\}', extract) if json_match: info = json.loads(json_match.group()) if info.get("name"): caller_service.update_screening(caller_id, caller_name=info["name"]) if info.get("topic"): caller_service.update_screening(caller_id, topic=info["topic"]) if info.get("name") and info.get("topic"): caller_service.end_screening(caller_id) broadcast_event("screening_complete", { "caller_id": caller_id, "name": info["name"], "topic": info["topic"] }) except Exception as e: print(f"[Screening] Extract failed: {e}") # TTS the screener response back to caller try: audio_bytes = await generate_speech(response, "Sarah", "none") if audio_bytes: await caller_service.stream_audio_to_caller(caller_id, audio_bytes, 24000) except Exception as e: print(f"[Screening] Response TTS failed: {e}") # Start hold music after screening completes and final TTS has played screening = caller_service.get_screening_state(caller_id) if screening and screening.get("status") == "complete" and caller_id not in _hold_music_tasks: _hold_music_tasks[caller_id] = asyncio.create_task(_stream_hold_music(caller_id)) @app.websocket("/api/signalwire/stream") async def signalwire_audio_stream(websocket: WebSocket): """Handle SignalWire bidirectional audio stream""" await websocket.accept() caller_id = str(uuid.uuid4())[:8] caller_phone = "Unknown" call_sid = "" audio_buffer = bytearray() screening_buffer = bytearray() CHUNK_DURATION_S = 3 SAMPLE_RATE = 16000 chunk_samples = CHUNK_DURATION_S * SAMPLE_RATE stream_started = False try: while True: message = await websocket.receive() if message.get("type") == "websocket.disconnect": break raw = message.get("text") if not raw: continue try: msg = json.loads(raw) except json.JSONDecodeError: continue event = msg.get("event") if event == "start": custom = msg.get("start", {}).get("customParameters", {}) caller_phone = custom.get("caller_phone", "Unknown") call_sid = custom.get("call_sid", "") stream_sid = msg.get("start", {}).get("streamSid", "") stream_started = True print(f"[SignalWire WS] Stream started: {caller_phone} (CallSid: {call_sid}, StreamSid: {stream_sid})") caller_service.add_to_queue(caller_id, caller_phone) caller_service.register_websocket(caller_id, websocket) broadcast_event("caller_queued", {"phone": caller_phone}) broadcast_chat("System", f"{caller_phone} is waiting in the queue") ring_sound = settings.sounds_dir / "phone_ring.wav" if ring_sound.exists(): threading.Thread(target=audio_service.play_sfx, args=(str(ring_sound),), daemon=True).start() if call_sid: caller_service.register_call_sid(caller_id, call_sid) if stream_sid: caller_service.register_stream_sid(caller_id, stream_sid) # Start screening conversation asyncio.create_task(_start_screening_greeting(caller_id)) elif event == "media" and stream_started: try: payload = msg.get("media", {}).get("payload", "") if not payload: continue pcm_data = base64.b64decode(payload) call_info = caller_service.active_calls.get(caller_id) if not call_info: # Caller is queued, not on air — route to screening screening_buffer.extend(pcm_data) if len(screening_buffer) >= chunk_samples * 2: pcm_chunk = bytes(screening_buffer[:chunk_samples * 2]) screening_buffer = screening_buffer[chunk_samples * 2:] audio_check = np.frombuffer(pcm_chunk, dtype=np.int16).astype(np.float32) / 32768.0 if np.abs(audio_check).max() >= 0.01: asyncio.create_task( _handle_screening_audio(caller_id, pcm_chunk, SAMPLE_RATE) ) continue audio_buffer.extend(pcm_data) audio_service.route_real_caller_audio(pcm_data, SAMPLE_RATE) if len(audio_buffer) >= chunk_samples * 2: pcm_chunk = bytes(audio_buffer[:chunk_samples * 2]) audio_buffer = audio_buffer[chunk_samples * 2:] # Skip transcription if audio is silent audio_check = np.frombuffer(pcm_chunk, dtype=np.int16).astype(np.float32) / 32768.0 if np.abs(audio_check).max() < 0.01: continue asyncio.create_task( _safe_transcribe(caller_id, pcm_chunk, SAMPLE_RATE) ) except Exception as e: print(f"[SignalWire WS] Media frame error (non-fatal): {e}") continue # Skip bad frame, don't disconnect caller elif event == "stop": print(f"[SignalWire WS] Stream stop event received: {caller_phone} (caller_id: {caller_id})") break except WebSocketDisconnect: on_air = caller_id in caller_service.active_calls tts_active = caller_service.is_streaming_tts(caller_id) started_at = caller_service.active_calls.get(caller_id, {}).get("started_at") duration = f"{time.time() - started_at:.0f}s" if started_at else "n/a" print(f"[SignalWire WS] DROPPED: {caller_id} ({caller_phone}) on_air={on_air} tts_active={tts_active} duration={duration}") disconnect_reason = "dropped" except Exception as e: print(f"[SignalWire WS] Error: {e}") traceback.print_exc() disconnect_reason = f"error: {e}" else: disconnect_reason = "clean" finally: _stop_hold_music(caller_id) was_on_air = caller_id in caller_service.active_calls caller_service.unregister_websocket(caller_id) caller_service.unregister_call_sid(caller_id) caller_service.unregister_stream_sid(caller_id) caller_service.remove_from_queue(caller_id) if was_on_air: caller_service.hangup(caller_id) if session.active_real_caller and session.active_real_caller.get("caller_id") == caller_id: session.active_real_caller = None broadcast_event("caller_disconnected", {"phone": caller_phone, "reason": disconnect_reason}) broadcast_chat("System", f"{caller_phone} disconnected ({disconnect_reason})") drop_sound = settings.sounds_dir / ("busy.wav" if disconnect_reason == "dropped" else "hangup.wav") if drop_sound.exists(): threading.Thread(target=audio_service.play_sfx, args=(str(drop_sound),), daemon=True).start() elif stream_started: broadcast_chat("System", f"{caller_phone} left the queue") if audio_buffer and caller_id in caller_service.active_calls: asyncio.create_task( _safe_transcribe(caller_id, bytes(audio_buffer), SAMPLE_RATE) ) async def _safe_transcribe(caller_id: str, pcm_chunk: bytes, sample_rate: int): """Wrapper that catches transcription errors so they don't crash anything""" try: await _handle_real_caller_transcription(caller_id, pcm_chunk, sample_rate) except Exception as e: print(f"[Transcription] Error (non-fatal): {e}") # --- Host Audio Broadcast --- _host_audio_queue: asyncio.Queue = None _host_audio_task: asyncio.Task = None async def _host_audio_sender(): """Persistent task that drains audio queue, batches frames, and sends to callers""" _send_count = [0] try: while True: pcm_bytes = await _host_audio_queue.get() if caller_service.is_streaming_tts_any(): continue # Drain all available frames and concatenate chunks = [pcm_bytes] while not _host_audio_queue.empty(): try: extra = _host_audio_queue.get_nowait() if not caller_service.is_streaming_tts_any(): chunks.append(extra) except asyncio.QueueEmpty: break combined = b''.join(chunks) t0 = time.time() for caller_id in list(caller_service.active_calls.keys()): try: await caller_service.send_audio_to_caller(caller_id, combined, 16000) except Exception: pass elapsed = time.time() - t0 _send_count[0] += 1 if _send_count[0] % 20 == 0: qsize = _host_audio_queue.qsize() audio_ms = len(combined) / 2 / 16000 * 1000 print(f"[HostAudio] send took {elapsed*1000:.0f}ms, {len(chunks)} chunks batched ({audio_ms:.0f}ms audio), queue: {qsize}") except asyncio.CancelledError: print("[HostAudio] Sender task cancelled") except Exception as e: print(f"[HostAudio] Sender task error: {e}") def _start_host_audio_sender(): """Start the persistent host audio sender task""" global _host_audio_queue, _host_audio_task if _host_audio_queue is None: _host_audio_queue = asyncio.Queue(maxsize=50) if _host_audio_task is None or _host_audio_task.done(): _host_audio_task = asyncio.create_task(_host_audio_sender()) def _host_audio_sync_callback(pcm_bytes: bytes): """Sync callback from audio thread — push to queue for async sending""" if _host_audio_queue is None: return try: _host_audio_queue.put_nowait(pcm_bytes) except asyncio.QueueFull: pass # Drop frame rather than block # --- Queue Endpoints --- @app.get("/api/queue") async def get_call_queue(): """Get list of callers waiting in queue""" return {"queue": caller_service.get_queue()} @app.post("/api/queue/take/{caller_id}") async def take_call_from_queue(caller_id: str): """Take a caller off hold and put them on air""" _stop_hold_music(caller_id) try: call_info = caller_service.take_call(caller_id) except ValueError as e: raise HTTPException(404, str(e)) session.active_real_caller = { "caller_id": call_info["caller_id"], "channel": call_info["channel"], "phone": call_info["phone"], } return { "status": "on_air", "caller": call_info, } @app.post("/api/queue/drop/{caller_id}") async def drop_from_queue(caller_id: str): """Drop a caller from the queue""" _stop_hold_music(caller_id) call_sid = caller_service.get_call_sid(caller_id) caller_service.remove_from_queue(caller_id) if call_sid: await _signalwire_end_call(call_sid) return {"status": "dropped"} _auto_respond_pending: asyncio.Task | None = None _auto_respond_buffer: list[str] = [] async def _handle_real_caller_transcription(caller_id: str, pcm_data: bytes, sample_rate: int): """Transcribe a chunk of real caller audio and add to conversation""" global _auto_respond_pending call_info = caller_service.active_calls.get(caller_id) if not call_info: return caller_phone = call_info["phone"] context_hint = f"A real caller ({caller_phone}) is talking to host Luke on the radio." text = await transcribe_audio(pcm_data, source_sample_rate=sample_rate, context_hint=context_hint) if not text or not text.strip(): return print(f"[Real Caller] {caller_phone}: {text}") # Add to conversation and broadcast to frontend session.add_message(f"real_caller:{caller_phone}", text) broadcast_chat(f"{caller_phone} (caller)", text) # If AI auto-respond mode is on and an AI caller is active, debounce auto-respond if session.ai_respond_mode == "auto" and session.current_caller_key: _auto_respond_buffer.append(text) # Cancel any pending auto-respond timer and restart it if _auto_respond_pending and not _auto_respond_pending.done(): _auto_respond_pending.cancel() _auto_respond_pending = asyncio.create_task(_debounced_auto_respond(caller_phone)) async def _debounced_auto_respond(caller_phone: str): """Wait for caller to stop talking (4s pause), then trigger AI response""" try: await asyncio.sleep(4) # Wait 4 seconds of silence except asyncio.CancelledError: return # More speech came in, timer restarted # Gather accumulated text accumulated = " ".join(_auto_respond_buffer) _auto_respond_buffer.clear() if not accumulated.strip(): return print(f"[Auto-Respond] Caller paused. Accumulated: {accumulated[:100]}...") await _trigger_ai_auto_respond(accumulated) async def _trigger_ai_auto_respond(accumulated_text: str): """Generate AI caller response to accumulated real caller speech""" epoch = _session_epoch if not session.caller: return if _ai_response_lock.locked(): return # Cooldown check if not hasattr(session, '_last_ai_auto_respond'): session._last_ai_auto_respond = 0 if time.time() - session._last_ai_auto_respond < 5: return ai_name = session.caller["name"] async with _ai_response_lock: if _session_epoch != epoch: return # Call changed while waiting for lock print(f"[Auto-Respond] {ai_name} is jumping in...") session._last_ai_auto_respond = time.time() audio_service.stop_caller_audio() broadcast_event("ai_status", {"text": f"{ai_name} is thinking..."}) show_history = session.get_show_history() is_wrapping = session._wrapping_up mood = detect_host_mood(session.conversation, wrapping_up=is_wrapping) if is_wrapping: session._wrapup_exchanges += 1 if session._wrapup_exchanges > 2: mood += "\nSay goodbye NOW and end with [HANGUP]\n" slim_caller = session.caller_backgrounds.get(session.current_caller_key, {}) system_prompt = get_caller_prompt(slim_caller, theme=session.show_theme) max_tokens, max_sentences = _pick_response_budget(wrapping_up=is_wrapping) messages = _normalize_messages_for_llm(session.conversation[-_dynamic_context_window():]) _caller_name = session.caller.get("name", "") if session.caller else "" _model_override = None # caller_dialog category routes to sonnet-4.6 response = await llm_service.generate( messages=messages, system_prompt=system_prompt, max_tokens=max_tokens, category="caller_dialog", caller_name=_caller_name, model_override=_model_override, ) response = await _retry_if_too_short( response, llm_service, messages, system_prompt, max_tokens, _caller_name, _model_override, wrapping_up=is_wrapping) if not is_wrapping and response and "[HANGUP]" not in response and _has_repetition(response, session.conversation): print(f"[Auto-Respond] Repetition detected, retrying...") retry_messages = messages + [{"role": "user", "content": "You're repeating yourself. Say something NEW — a detail you haven't mentioned, a different angle, or move the story forward. Do not repeat facts you've already stated."}] retry_response = await llm_service.generate( messages=retry_messages, system_prompt=system_prompt, max_tokens=max_tokens, category="caller_dialog", caller_name=_caller_name, model_override=_model_override, ) if retry_response and not _has_repetition(retry_response, session.conversation): print(f"[Auto-Respond] Anti-repetition retry succeeded") response = retry_response else: print(f"[Auto-Respond] Anti-repetition retry no better, keeping original") # Discard if call changed during generation if _session_epoch != epoch: print(f"[Auto-Respond] Discarding stale response (epoch {epoch} → {_session_epoch})") broadcast_event("ai_done") return response = clean_for_tts(response, formal=False) response = _trim_to_sentences(response, max_sentences) response = ensure_complete_thought(response) # Detect [HANGUP] sentinel caller_hangup = "[HANGUP]" in response if caller_hangup: response = response.replace("[HANGUP]", "").strip() session._caller_hangup = True print(f"[Auto-Respond] Caller hangup detected") if not response or not response.strip(): broadcast_event("ai_done") return # Final staleness check before playing audio if _session_epoch != epoch: broadcast_event("ai_done") return session.add_message(f"ai_caller:{ai_name}", response) broadcast_chat(ai_name, response) broadcast_event("ai_status", {"text": f"{ai_name} is speaking..."}) _caller_bg = session.caller_backgrounds.get(session.current_caller_key) or {} _emotional_register = _caller_bg.get("emotional_register", "") if isinstance(_caller_bg, dict) else "" try: audio_bytes = await generate_speech(response, session.caller["voice"], "none", provider_override=session.caller.get("tts_provider"), emotional_register=_emotional_register) except Exception as e: print(f"[Auto-Respond] TTS failed: {e}") broadcast_event("ai_done") return # Don't play if call changed during TTS generation if _session_epoch != epoch: print(f"[Auto-Respond] Discarding stale TTS (epoch {epoch} → {_session_epoch})") broadcast_event("ai_done") return thread = threading.Thread( target=audio_service.play_caller_audio, args=(audio_bytes, 24000), daemon=True, ) thread.start() broadcast_event("ai_done") # Signal caller hangup to frontend if caller_hangup: broadcast_event("caller_hangup", {"caller": ai_name}) # Also stream to active real caller so they hear the AI if session.active_real_caller: caller_id = session.active_real_caller["caller_id"] asyncio.create_task( caller_service.stream_audio_to_caller(caller_id, audio_bytes, 24000) ) @app.post("/api/ai-respond") async def ai_respond(): """Trigger AI caller to respond based on current conversation""" if not session.caller: raise HTTPException(400, "No active AI caller") epoch = _session_epoch try: async with _ai_response_lock: if _session_epoch != epoch: raise HTTPException(409, "Call ended while waiting") audio_service.stop_caller_audio() show_history = session.get_show_history() is_wrapping = session._wrapping_up mood = detect_host_mood(session.conversation, wrapping_up=is_wrapping) if is_wrapping: session._wrapup_exchanges += 1 if session._wrapup_exchanges > 2: mood += "\nSay goodbye NOW and end with [HANGUP]\n" slim_caller = session.caller_backgrounds.get(session.current_caller_key, {}) system_prompt = get_caller_prompt(slim_caller, theme=session.show_theme) max_tokens, max_sentences = _pick_response_budget(wrapping_up=is_wrapping) messages = _normalize_messages_for_llm(session.conversation[-_dynamic_context_window():]) _caller_name = session.caller.get("name", "") if session.caller else "" _model_override = None # caller_dialog category routes to sonnet-4.6 response = await llm_service.generate( messages=messages, system_prompt=system_prompt, max_tokens=max_tokens, category="caller_dialog", caller_name=_caller_name, model_override=_model_override, ) response = await _retry_if_too_short( response, llm_service, messages, system_prompt, max_tokens, _caller_name, _model_override, wrapping_up=is_wrapping) if not is_wrapping and response and "[HANGUP]" not in response and _has_repetition(response, session.conversation): print(f"[Chat] Repetition detected, retrying with anti-repetition prompt...") retry_messages = messages + [{"role": "user", "content": "You're repeating yourself. Say something NEW — a detail you haven't mentioned, a different angle, or move the story forward. Do not repeat facts you've already stated."}] retry_response = await llm_service.generate( messages=retry_messages, system_prompt=system_prompt, max_tokens=max_tokens, category="caller_dialog", caller_name=_caller_name, model_override=_model_override, ) if retry_response and not _has_repetition(retry_response, session.conversation): print(f"[Chat] Anti-repetition retry succeeded") response = retry_response else: print(f"[Chat] Anti-repetition retry no better, keeping original") except HTTPException: raise except Exception as e: print(f"[AI-Respond] LLM error: {e}") response = "Sorry, I blanked out for a second there. What was that?" if _session_epoch != epoch: raise HTTPException(409, "Call changed during response") response = clean_for_tts(response, formal=False) response = _trim_to_sentences(response, max_sentences) response = ensure_complete_thought(response) # Detect [HANGUP] sentinel caller_hangup = "[HANGUP]" in response if caller_hangup: response = response.replace("[HANGUP]", "").strip() session._caller_hangup = True print(f"[AI-Respond] Caller hangup detected") if not response or not response.strip(): response = "Uh... sorry, what was that?" # Snapshot caller info before it can be cleared by a concurrent hangup caller = session.caller if not caller: raise HTTPException(409, "Call ended") ai_name = caller["name"] ai_voice = caller["voice"] ai_tts_provider = caller.get("tts_provider") _caller_bg = session.caller_backgrounds.get(session.current_caller_key) or {} ai_emotional_register = _caller_bg.get("emotional_register", "") if isinstance(_caller_bg, dict) else "" # TTS — outside the lock so other requests aren't blocked try: audio_bytes = await generate_speech(response, ai_voice, "none", provider_override=ai_tts_provider, emotional_register=ai_emotional_register) except Exception as e: print(f"[AI-Respond] TTS failed: {e}") broadcast_event("ai_done") return {"text": response, "caller": ai_name, "tts_error": str(e)} # Add message AFTER successful TTS so ghost messages don't pollute conversation session.add_message(f"ai_caller:{ai_name}", response) if _session_epoch != epoch: raise HTTPException(409, "Call changed during TTS") thread = threading.Thread( target=audio_service.play_caller_audio, args=(audio_bytes, 24000), daemon=True, ) thread.start() # Stream to real caller if session.active_real_caller: caller_id = session.active_real_caller["caller_id"] asyncio.create_task( caller_service.stream_audio_to_caller(caller_id, audio_bytes, 24000) ) result = { "text": response, "caller": ai_name, "voice_id": session.caller["voice"] } if caller_hangup: result["hangup"] = True return result # --- Follow-Up & Session Control Endpoints --- @app.post("/api/hangup/real") async def hangup_real_caller(): """Hang up on real caller — disconnect immediately, summarize in background""" global _session_epoch, _auto_respond_pending if not session.active_real_caller: raise HTTPException(400, "No active real caller") _session_epoch += 1 # Cancel any pending auto-respond if _auto_respond_pending and not _auto_respond_pending.done(): _auto_respond_pending.cancel() _auto_respond_pending = None _auto_respond_buffer.clear() if session._research_task and not session._research_task.done(): session._research_task.cancel() session._research_task = None caller_id = session.active_real_caller["caller_id"] caller_phone = session.active_real_caller["phone"] conversation_snapshot = list(session.conversation) call_started = getattr(session, '_call_started_at', 0.0) auto_followup_enabled = session.auto_followup # End the phone call via SignalWire call_sid = caller_service.get_call_sid(caller_id) caller_service.hangup(caller_id) if call_sid: asyncio.create_task(_signalwire_end_call(call_sid)) session.active_real_caller = None hangup_sound = settings.sounds_dir / "hangup.wav" if hangup_sound.exists(): threading.Thread(target=audio_service.play_sfx, args=(str(hangup_sound),), daemon=True).start() asyncio.create_task( _summarize_real_call(caller_phone, conversation_snapshot, call_started, auto_followup_enabled) ) return { "status": "disconnected", "caller": caller_phone, } async def _summarize_real_call(caller_phone: str, conversation: list, started_at: float, auto_followup_enabled: bool): """Background task: summarize call and store in history""" ended_at = time.time() summary = "" if conversation: transcript_text = "\n".join( f"{msg['role']}: {msg['content']}" for msg in conversation ) summary = await llm_service.generate( messages=[{"role": "user", "content": f"Summarize this radio show call in 1-2 sentences:\n{transcript_text}"}], system_prompt="You summarize radio show conversations concisely. Focus on what the caller talked about and any emotional moments.", category="call_summary", caller_name=caller_phone, ) quality_signals = _assess_call_quality(conversation) session.call_quality_signals.append(quality_signals) session.call_history.append(CallRecord( caller_type="real", caller_name=caller_phone, summary=summary, transcript=conversation, started_at=started_at, ended_at=ended_at, quality_signals=quality_signals, )) print(f"[Real Caller] {caller_phone} call summarized: {summary[:80]}...") print(f"[Quality] {caller_phone}: exchanges={quality_signals['exchange_count']} avg_len={quality_signals['avg_response_length']:.0f}c host_engagement={quality_signals['host_engagement']} caller_depth={quality_signals['caller_depth']} natural_end={quality_signals['natural_ending']}") _save_checkpoint() if auto_followup_enabled: await _auto_followup(summary) async def _auto_followup(last_call_summary: str): """Automatically pick an AI caller and connect them as follow-up""" await asyncio.sleep(7) # Brief pause before follow-up # Ask LLM to pick best AI caller for follow-up caller_list = ", ".join( f'{k}: {v["name"]} ({v["gender"]}, {v["age_range"][0]}-{v["age_range"][1]})' for k, v in CALLER_BASES.items() ) pick = await llm_service.generate( messages=[{"role": "user", "content": f'A caller just talked about: "{last_call_summary}". Which AI caller should follow up? Available: {caller_list}. Reply with just the key number.'}], system_prompt="Pick the most interesting AI caller to follow up on this topic. Just reply with the number key.", category="followup_pick", ) # Extract key from response match = re.search(r'\d+', pick) if match: caller_key = match.group() if caller_key in CALLER_BASES: session.start_call(caller_key) print(f"[Auto Follow-Up] {CALLER_BASES[caller_key]['name']} is calling in about: {last_call_summary[:50]}...") @app.post("/api/followup/generate") async def generate_followup(): """Generate an AI follow-up caller based on recent show history""" if not session.call_history: raise HTTPException(400, "No call history to follow up on") last_record = session.call_history[-1] await _auto_followup(last_record.summary) return { "status": "followup_triggered", "based_on": last_record.caller_name, } @app.post("/api/session/ai-mode") async def set_ai_mode(data: dict): """Set AI respond mode (manual or auto)""" mode = data.get("mode", "manual") session.ai_respond_mode = mode print(f"[Session] AI respond mode: {mode}") return {"mode": mode} @app.post("/api/session/auto-followup") async def set_auto_followup(data: dict): """Toggle auto follow-up""" session.auto_followup = data.get("enabled", False) print(f"[Session] Auto follow-up: {session.auto_followup}") return {"enabled": session.auto_followup} # --- Intern (Devon) Endpoints --- @app.post("/api/intern/ask") async def intern_ask(data: dict): """Host asks Devon to look something up""" question = data.get("question", "").strip() if not question: raise HTTPException(400, "No question provided") # Run research + response (non-blocking for the caller audio) result = await intern_service.ask( question=question, conversation_context=session.conversation if session.conversation else None, caller_active=session.caller is not None, ) text = result.get("text", "") if not text: return {"text": None, "sources": []} # Add to conversation log session.add_message(f"intern:{intern_service.name}", text) broadcast_event("intern_response", {"text": text, "intern": intern_service.name}) # TTS — play Devon's voice on air (no phone filter, in-studio) asyncio.create_task(_play_intern_audio(text)) return { "text": text, "sources": result.get("sources", []), "intern": intern_service.name, } @app.post("/api/intern/interject") async def intern_interject(): """Manually trigger Devon to comment on current conversation""" if not session.conversation: raise HTTPException(400, "No active conversation") result = await intern_service.interject(session.conversation, caller_active=session.caller is not None) if not result: return {"text": None} text = result["text"] session.add_message(f"intern:{intern_service.name}", text) broadcast_event("intern_response", {"text": text, "intern": intern_service.name}) asyncio.create_task(_play_intern_audio(text)) return { "text": text, "sources": result.get("sources", []), "intern": intern_service.name, } @app.post("/api/intern/monitor") async def intern_monitor(data: dict): """Toggle Devon's auto-monitoring on/off""" enabled = data.get("enabled", True) session.intern_monitoring = enabled if enabled: async def _on_suggestion(text, sources): broadcast_event("intern_suggestion", {"text": text, "sources": sources}) intern_service.start_monitoring( get_conversation=lambda: session.conversation, on_suggestion=_on_suggestion, ) else: intern_service.stop_monitoring() print(f"[Intern] Monitoring: {enabled}") return {"monitoring": enabled} @app.get("/api/intern/suggestion") async def intern_suggestion(): """Get Devon's pending suggestion (if any)""" suggestion = intern_service.get_pending_suggestion() return {"suggestion": suggestion} @app.post("/api/intern/suggestion/play") async def intern_play_suggestion(): """Approve and play Devon's pending suggestion on air""" suggestion = intern_service.get_pending_suggestion() if not suggestion: raise HTTPException(400, "No pending suggestion") text = suggestion["text"] intern_service.dismiss_suggestion() session.add_message(f"intern:{intern_service.name}", text) broadcast_event("intern_response", {"text": text, "intern": intern_service.name}) asyncio.create_task(_play_intern_audio(text)) return {"text": text, "intern": intern_service.name} @app.post("/api/intern/suggestion/dismiss") async def intern_dismiss_suggestion(): """Dismiss Devon's pending suggestion""" intern_service.dismiss_suggestion() return {"dismissed": True} async def _play_intern_audio(text: str): """Generate TTS for Devon and play on air (no phone filter, own stem + channel)""" try: audio_bytes = await generate_speech( text, intern_service.voice, apply_filter=False ) thread = threading.Thread( target=audio_service.play_caller_audio, args=(audio_bytes, 24000), kwargs={"stem_name": "devon", "channel_override": audio_service.devon_channel}, daemon=True, ) thread.start() except Exception as e: print(f"[Intern] TTS failed: {e}") # --- 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""" 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: 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") # --- Transcript & Chapter Export --- @app.get("/api/session/export") async def export_session(): """Export session transcript with speaker labels and chapters from call boundaries""" if not session.call_history: raise HTTPException(400, "No calls in this session to export") # Find the earliest call start as session base time session_start = min( (r.started_at for r in session.call_history if r.started_at > 0), default=time.time() ) transcript_lines = [] chapters = [] for i, record in enumerate(session.call_history): # Chapter from call start time offset_seconds = max(0, record.started_at - session_start) if record.started_at > 0 else 0 chapter_title = f"{record.caller_name}" if record.summary: # Use first sentence of summary for chapter title short_summary = record.summary.split(".")[0].strip() if short_summary: chapter_title += f" \u2014 {short_summary}" chapters.append({"startTime": round(offset_seconds), "title": chapter_title}) # Separator between calls if i > 0: transcript_lines.append("") transcript_lines.append(f"--- Call {i + 1}: {record.caller_name} ---") transcript_lines.append("") # Transcript lines with timestamps for msg in record.transcript: msg_offset = msg.get("timestamp", 0) - session_start if msg.get("timestamp") else offset_seconds if msg_offset < 0: msg_offset = 0 mins = int(msg_offset // 60) secs = int(msg_offset % 60) role = msg.get("role", "") if role in ("user", "host"): speaker = "HOST" elif role.startswith("real_caller:"): speaker = role.split(":", 1)[1].upper() elif role.startswith("ai_caller:"): speaker = role.split(":", 1)[1].upper() elif role == "assistant": speaker = record.caller_name.upper() else: speaker = role.upper() transcript_lines.append(f"[{mins:02d}:{secs:02d}] {speaker}: {msg['content']}") return { "session_id": session.id, "transcript": "\n".join(transcript_lines), "chapters": chapters, "call_count": len(session.call_history), } # --- Server Control Endpoints --- import subprocess from collections import deque # In-memory log buffer _log_buffer = deque(maxlen=500) def add_log(message: str): """Add a message to the log buffer""" import datetime timestamp = datetime.datetime.now().strftime("%H:%M:%S") _log_buffer.append(f"[{timestamp}] {message}") # Override print to also log to buffer import builtins _original_print = builtins.print def _logging_print(*args, **kwargs): try: _original_print(*args, **kwargs) except (BrokenPipeError, OSError): pass # Ignore broken pipe errors from traceback printing try: message = " ".join(str(a) for a in args) if message.strip(): add_log(message) except Exception: pass # Don't let logging errors break the app builtins.print = _logging_print @app.get("/api/logs") async def get_logs(lines: int = 100): """Get recent log lines""" log_lines = list(_log_buffer)[-lines:] return {"logs": log_lines} @app.post("/api/server/restart") async def restart_server(): """Signal the server to restart (requires run.sh wrapper)""" restart_flag = Path("/tmp/ai-radio-show.restart") restart_flag.touch() add_log("Restart signal sent - server will restart shortly") return {"status": "restarting"} @app.post("/api/server/stop") async def stop_server(): """Signal the server to stop (requires run.sh wrapper)""" stop_flag = Path("/tmp/ai-radio-show.stop") stop_flag.touch() add_log("Stop signal sent - server will stop shortly") return {"status": "stopping"} @app.get("/api/server/status") async def server_status(): """Get server status info""" return { "status": "running", "tts_provider": settings.tts_provider, "llm_provider": llm_service.provider, "session_id": session.id } # --- Stem Recording --- @app.post("/api/recording/toggle") async def toggle_stem_recording(): """Toggle recording on/off. Also toggles on-air state.""" global _show_on_air if audio_service.stem_recorder is None: # START recording from datetime import datetime dir_name = datetime.now().strftime("%Y-%m-%d_%H%M%S") recordings_dir = Path("recordings") / dir_name import sounddevice as sd device_info = sd.query_devices(audio_service.output_device) if audio_service.output_device is not None else None sr = int(device_info["default_samplerate"]) if device_info else 48000 recorder = StemRecorder(recordings_dir, sample_rate=sr) recorder.start() audio_service.stem_recorder = recorder audio_service.start_stem_mic() add_log(f"Stem recording started -> {recordings_dir}") if not _show_on_air: _show_on_air = True _start_host_audio_sender() audio_service.start_host_stream(_host_audio_sync_callback) threading.Thread(target=_update_on_air_cdn, args=(True,), daemon=True).start() threading.Thread(target=_start_ngrok, daemon=True).start() add_log("Show auto-set to ON AIR") return {"on_air": _show_on_air, "recording": True, "caller_line_ready": _caller_line_ready} # STOP recording audio_service.stop_stem_mic() stems_dir = audio_service.stem_recorder.output_dir paths = audio_service.stem_recorder.stop() audio_service.stem_recorder = None add_log(f"Stem recording stopped. Running post-production...") # Save cost report for this session session_id = stems_dir.name cost_report_path = Path("data/cost_reports") / f"session-{session_id}.json" cost_tracker.save(cost_report_path) summary = cost_tracker.get_live_summary() add_log(f"Session costs: ${summary['total_cost_usd']:.4f} " f"(LLM: ${summary['llm_cost_usd']:.4f}, TTS: ${summary['tts_cost_usd']:.4f}, " f"{summary['total_llm_calls']} calls, {summary['total_tokens']} tokens)") by_cat = summary.get("by_category", {}) if by_cat: breakdown = ", ".join(f"{k}: ${v['cost']:.4f}/{v['calls']}calls" for k, v in sorted(by_cat.items(), key=lambda x: x[1]["cost"], reverse=True)) add_log(f"Cost breakdown: {breakdown}") if _show_on_air: _show_on_air = False audio_service.stop_host_stream() threading.Thread(target=_update_on_air_cdn, args=(False,), daemon=True).start() threading.Thread(target=_stop_ngrok, daemon=True).start() add_log("Show auto-set to OFF AIR") # Auto-run postprod in background import subprocess, sys python = sys.executable output_file = stems_dir / "episode.mp3" def _run_postprod(): try: result = subprocess.run( [python, "postprod.py", str(stems_dir), "-o", "episode.mp3"], capture_output=True, text=True, timeout=600, ) if result.returncode == 0: add_log(f"Post-production complete -> {output_file}") else: add_log(f"Post-production failed: {result.stderr[:300]}") except Exception as e: add_log(f"Post-production error: {e}") threading.Thread(target=_run_postprod, daemon=True).start() return {"on_air": _show_on_air, "recording": False, "caller_line_ready": _caller_line_ready} @app.post("/api/recording/process") async def process_stems(stems_dir: str): import subprocess stems_path = Path(stems_dir).resolve() allowed_root = Path("recordings").resolve() if not str(stems_path).startswith(str(allowed_root)): raise HTTPException(403, "Path must be under the recordings/ directory") if not stems_path.exists(): raise HTTPException(404, f"Directory not found: {stems_dir}") output_file = stems_path / "episode.mp3" try: result = subprocess.run( ["python", "postprod.py", str(stems_path), "-o", str(output_file)], capture_output=True, text=True, timeout=300, ) if result.returncode != 0: raise HTTPException(500, f"Processing failed: {result.stderr}") add_log(f"Post-production complete -> {output_file}") return {"status": "done", "output": str(output_file)} except subprocess.TimeoutExpired: raise HTTPException(504, "Processing timed out")