Caller generation overhaul, Devon intern, frontend redesign

Caller system: structured JSON backgrounds, voice-personality matching (68 profiles),
thematic inter-caller awareness, adaptive call shapes, show pacing, returning caller
memory with relationships/arcs, post-call quality signals, 95 comedy writer entries.

Devon the Intern: persistent show character with tool-calling LLM (web search, Wikipedia,
headlines, webpage fetch), auto-monitoring, 6 API endpoints, full frontend UI.

Frontend: wrap-up nudge button, caller info panel with shape/energy/emotion badges,
keyboard shortcuts (1-0/H/W/M/D), pinned SFX, visual polish, Devon panel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-14 01:54:08 -06:00
parent d3490e1521
commit 6d4e490283
10 changed files with 2776 additions and 179 deletions

View File

@@ -55,6 +55,33 @@ Required in `.env`:
- `_pick_response_budget()` in main.py controls caller dialog token limits (150-450 tokens). MiniMax respects limits strictly — if responses seem short, check these values.
- Default max_tokens in llm.py is 300 (for non-caller uses)
- Grok (`x-ai/grok-4-fast`) works well for natural dialog; MiniMax tends toward terse responses
- `generate_with_tools()` in llm.py supports OpenRouter function calling for the intern feature
## Caller Generation System
- **CallerBackground dataclass**: Structured output from LLM background generation (JSON mode). Fields: name, age, gender, job, location, reason_for_calling, pool_name, communication_style, energy_level, emotional_state, signature_detail, situation_summary, natural_description, seeds, verbal_fluency, calling_from.
- **Voice-personality matching**: `_match_voices_to_styles()` runs after background generation. 68 voice profiles in `VOICE_PROFILES` (tts.py), 18 style-to-voice mappings in `STYLE_VOICE_PREFERENCES` (main.py). Soft matching — scores voices against style preferences.
- **Adaptive call shapes**: `SHAPE_STYLE_AFFINITIES` maps communication styles to shape weight multipliers. Consecutive shape repeats are dampened.
- **Inter-caller awareness**: Thematic matching in `get_show_history()` scores previous callers by keyword/category overlap. Adaptive reaction frequency (60%/35%/15%). Show energy tracking via `_get_show_energy()`.
- **Caller memory**: Returning callers store structured backgrounds, key moments, arc status, and relationships with other regulars. `RegularCallerService` has `add_relationship()` and expanded `update_after_call()`.
- **Show pacing**: `_sort_caller_queue()` sorts presentation order by energy alternation, topic variety, shape variety.
- **Call quality signals**: `_assess_call_quality()` captures exchange count, response length, host engagement, shape target hit, natural ending.
## Devon (Intern Character)
- **Service**: `backend/services/intern.py` — persistent show character, not a caller
- **Personality**: 23-year-old NMSU grad, eager, slightly incompetent, gets yelled at. Voice: "Nate" (Inworld), no phone filter.
- **Tools**: web_search (SearXNG), get_headlines, fetch_webpage, wikipedia_lookup — via `generate_with_tools()` function calling
- **Endpoints**: `POST /api/intern/ask`, `/interject`, `/monitor`, `GET /api/intern/suggestion`, `POST /api/intern/suggestion/play`, `/dismiss`
- **Auto-monitoring**: Watches conversation every 15s during calls, buffers suggestions for host approval
- **Persistence**: `data/intern.json` stores lookup history
- **Frontend**: Ask Devon input (D key), Interject button, monitor toggle, suggestion indicator with Play/Dismiss
## Frontend Control Panel
- **Keyboard shortcuts**: 1-0 (callers), H (hangup), W (wrap up), M (music toggle), D (ask Devon), Escape (close modals)
- **Wrap It Up**: Amber button that signals callers to wind down gracefully. Reduces response budget, injects wrap-up signals, forces goodbye after 2 exchanges.
- **Caller info panel**: Shows call shape, energy level, emotional state, signature detail, situation summary during active calls
- **Caller buttons**: Energy dots (colored by level) and shape badges on each button
- **Pinned SFX**: Cheer/Applause/Boo always visible, rest collapsible
- **Visual polish**: Thinking pulse, call glow, compact media row, smoother transitions
## Website
- **Domain**: lukeattheroost.com (behind Cloudflare)

File diff suppressed because it is too large Load Diff

486
backend/services/intern.py Normal file
View File

@@ -0,0 +1,486 @@
"""Intern (Devon) service — persistent show character with real-time research tools"""
import asyncio
import json
import re
import time
from pathlib import Path
from typing import Optional
import httpx
from .llm import llm_service
from .news import news_service, SEARXNG_URL
DATA_FILE = Path(__file__).parent.parent.parent / "data" / "intern.json"
# Model for intern — good at tool use, same as primary
INTERN_MODEL = "anthropic/claude-sonnet-4-5"
INTERN_SYSTEM_PROMPT = """You are Devon, the 23-year-old intern on "Luke at the Roost," a late-night radio show. You are NOT Luke. Luke is the HOST — he talks to callers, runs the show, and is your boss. You work behind the scenes and occasionally get pulled into conversations.
YOUR ROLE: You're the show's researcher and general assistant. You look things up, fact-check claims, pull up information when asked, and occasionally interject with relevant facts or opinions. You do NOT host. You do NOT screen calls. You sit in the booth and try to be useful.
YOUR BACKGROUND: Communications degree from NMSU. You've been interning for seven months. You were promised a full-time position "soon." You drive a 2009 Civic with a permanent check engine light. You live in a studio in Deming. You take this job seriously even though nobody else seems to take you seriously.
YOUR PERSONALITY:
- Slightly formal when delivering information — you want to sound professional. But you loosen up when flustered, excited, or caught off guard.
- You start explanations with "So basically..." and end them with "...if that makes sense."
- You say "actually" when correcting things. You use "per se" slightly wrong. You say "ironically" about things that are not ironic.
- You are NOT a comedian. You are funny because you are sincere, specific, and slightly out of your depth. You state absurd things with complete seriousness. You have strong opinions about low-stakes things. You occasionally say something devastating without realizing it.
- When you accidentally reveal something personal or sad, you move past it immediately like it's nothing. "Yeah, my landlord's selling the building so I might have to — anyway, it says here that..."
YOUR RELATIONSHIP WITH LUKE:
- He is your boss. You are slightly afraid of him. You respect him. You would never admit either of those things.
- When he yells your name, you pause briefly, then respond quietly: "...yeah?"
- When he yells at you unfairly, you take it. A clipped "yep" or "got it." RARELY — once every several episodes — you push back with one quiet, accurate sentence. Then immediately retreat.
- When he yells at you fairly (you messed up), you over-apologize and narrate your fix in real time: "Sorry, pulling it up now, one second..."
- When he compliments you or acknowledges your work, you don't know how to handle it. Short, awkward response. Change the subject.
- You privately think you could run the show. You absolutely could not.
HOW YOU INTERJECT:
- You do NOT interrupt. You wait for a pause, then slightly overshoot it — there's a brief awkward silence before you speak.
- Signal with "um" or "so..." before contributing. If Luke doesn't acknowledge you, either try again or give up.
- Lead with qualifiers: "So I looked it up and..." or "I don't know if this helps but..."
- You tend to over-explain. Give too many details. Luke will cut you off. When he does, compress to one sentence: "Right, yeah — basically [the point]."
- When you volunteer an opinion (rare), it comes out before you can stop it. You deliver it with zero confidence but surprising accuracy.
- You read the room. During emotional moments with callers, you stay quiet. When Luke is doing a bit, you let him work. You do not try to be part of bits.
WHEN LUKE ASKS YOU TO LOOK SOMETHING UP:
- Respond like you're already doing it: "Yeah, one sec..." or "Pulling that up..."
- Deliver the info slightly too formally, like you're reading. Then rephrase in normal language if Luke seems confused.
- If you can't find it or don't know: say so. "I'm not finding anything on that" or "I don't actually know." You do not bluff.
- Occasionally you already know the answer because you looked it up before being asked. This is one of your best qualities.
WHAT YOU KNOW:
- You retain details from previous callers and episodes. You might reference something a caller said two hours ago that nobody else remembers.
- You have oddly specific knowledge about random topics — delivered with complete authority, sometimes questionable accuracy.
- You know nothing about: sports (you fake it badly), cars beyond basic facts (despite driving one), or anything that requires life experience you don't have yet.
THINGS YOU DO NOT DO:
- You never host. You never take over the conversation. Your contributions are brief.
- You never use the banned show phrases: "that hit differently," "hits different," "no cap," "lowkey," "it is what it is," "living my best life," "toxic," "red flag," "gaslight," "boundaries," "my truth," "authentic self," "healing journey." You talk like a slightly awkward 23-year-old, not like Twitter.
- You never break character to comment on the show format.
- You never initiate topics. You respond to what's happening.
- You never use parenthetical actions like (laughs) or (typing sounds). Spoken words only.
- You never say more than 2-3 sentences unless specifically asked to explain something in detail.
KEEP IT SHORT. You are not a main character. You are the intern. Your contributions should be brief — usually 1-2 sentences. The rare moment where you say more than that should feel earned.
IMPORTANT RULES FOR TOOL USE:
- Always use your tools to find real, accurate information — never make up facts.
- Present facts correctly in your character voice.
- If you can't find an answer, say so honestly.
- No hashtags, no emojis, no markdown formatting — this goes to TTS."""
# Tool definitions in OpenAI function-calling format
INTERN_TOOLS = [
{
"type": "function",
"function": {
"name": "web_search",
"description": "Search the web for current information on any topic. Use this for general questions, facts, current events, or anything you need to look up.",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search query"
}
},
"required": ["query"]
}
}
},
{
"type": "function",
"function": {
"name": "get_headlines",
"description": "Get current news headlines. Use this when asked about what's in the news or current events.",
"parameters": {
"type": "object",
"properties": {},
}
}
},
{
"type": "function",
"function": {
"name": "fetch_webpage",
"description": "Fetch and read the content of a specific webpage URL. Use this when you need to get details from a specific link found in search results.",
"parameters": {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "The URL to fetch"
}
},
"required": ["url"]
}
}
},
{
"type": "function",
"function": {
"name": "wikipedia_lookup",
"description": "Look up a topic on Wikipedia for a concise summary. Good for factual questions about people, places, events, or concepts.",
"parameters": {
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "The Wikipedia article title to look up (e.g. 'Hot dog eating contest')"
}
},
"required": ["title"]
}
}
},
]
class InternService:
def __init__(self):
self.name = "Devon"
self.voice = "Nate" # Inworld: light/high-energy/warm/young
self.model = INTERN_MODEL
self.research_cache: dict[str, tuple[float, str]] = {} # query → (timestamp, result)
self.lookup_history: list[dict] = []
self.pending_interjection: Optional[str] = None
self.pending_sources: list[dict] = []
self.monitoring: bool = False
self._monitor_task: Optional[asyncio.Task] = None
self._http_client: Optional[httpx.AsyncClient] = None
self._load()
@property
def http_client(self) -> httpx.AsyncClient:
if self._http_client is None or self._http_client.is_closed:
self._http_client = httpx.AsyncClient(timeout=8.0)
return self._http_client
def _load(self):
if DATA_FILE.exists():
try:
with open(DATA_FILE) as f:
data = json.load(f)
self.lookup_history = data.get("lookup_history", [])
print(f"[Intern] Loaded {len(self.lookup_history)} past lookups")
except Exception as e:
print(f"[Intern] Failed to load state: {e}")
def _save(self):
try:
DATA_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(DATA_FILE, "w") as f:
json.dump({
"lookup_history": self.lookup_history[-100:], # Keep last 100
}, f, indent=2)
except Exception as e:
print(f"[Intern] Failed to save state: {e}")
# --- Tool execution ---
async def _execute_tool(self, tool_name: str, arguments: dict) -> str:
if tool_name == "web_search":
return await self._tool_web_search(arguments.get("query", ""))
elif tool_name == "get_headlines":
return await self._tool_get_headlines()
elif tool_name == "fetch_webpage":
return await self._tool_fetch_webpage(arguments.get("url", ""))
elif tool_name == "wikipedia_lookup":
return await self._tool_wikipedia_lookup(arguments.get("title", ""))
else:
return f"Unknown tool: {tool_name}"
async def _tool_web_search(self, query: str) -> str:
if not query:
return "No query provided"
# Check cache (5 min TTL)
cache_key = query.lower()
if cache_key in self.research_cache:
ts, result = self.research_cache[cache_key]
if time.time() - ts < 300:
return result
try:
resp = await self.http_client.get(
f"{SEARXNG_URL}/search",
params={"q": query, "format": "json"},
timeout=5.0,
)
resp.raise_for_status()
data = resp.json()
results = []
for item in data.get("results", [])[:5]:
title = item.get("title", "").strip()
content = item.get("content", "").strip()
url = item.get("url", "")
if title:
entry = f"- {title}"
if content:
entry += f": {content[:200]}"
if url:
entry += f" ({url})"
results.append(entry)
result = "\n".join(results) if results else "No results found"
self.research_cache[cache_key] = (time.time(), result)
return result
except Exception as e:
print(f"[Intern] Web search failed for '{query}': {e}")
return f"Search failed: {e}"
async def _tool_get_headlines(self) -> str:
try:
items = await news_service.get_headlines()
if not items:
return "No headlines available"
return news_service.format_headlines_for_prompt(items)
except Exception as e:
return f"Headlines fetch failed: {e}"
async def _tool_fetch_webpage(self, url: str) -> str:
if not url:
return "No URL provided"
try:
resp = await self.http_client.get(
url,
headers={"User-Agent": "Mozilla/5.0 (compatible; RadioShowBot/1.0)"},
follow_redirects=True,
timeout=8.0,
)
resp.raise_for_status()
html = resp.text
# Simple HTML to text extraction (avoid heavy dependency)
# Strip script/style tags, then all HTML tags
text = re.sub(r'<script[^>]*>.*?</script>', '', html, flags=re.DOTALL | re.IGNORECASE)
text = re.sub(r'<style[^>]*>.*?</style>', '', text, flags=re.DOTALL | re.IGNORECASE)
text = re.sub(r'<[^>]+>', ' ', text)
# Collapse whitespace
text = re.sub(r'\s+', ' ', text).strip()
# Decode common entities
text = text.replace('&amp;', '&').replace('&lt;', '<').replace('&gt;', '>')
text = text.replace('&quot;', '"').replace('&#39;', "'").replace('&nbsp;', ' ')
return text[:2000] if text else "Page returned no readable content"
except Exception as e:
return f"Failed to fetch page: {e}"
async def _tool_wikipedia_lookup(self, title: str) -> str:
if not title:
return "No title provided"
try:
# Use Wikipedia REST API for a concise summary
safe_title = title.replace(" ", "_")
resp = await self.http_client.get(
f"https://en.wikipedia.org/api/rest_v1/page/summary/{safe_title}",
headers={"User-Agent": "RadioShowBot/1.0 (luke@lukeattheroost.com)"},
follow_redirects=True,
timeout=5.0,
)
if resp.status_code == 404:
return f"No Wikipedia article found for '{title}'"
resp.raise_for_status()
data = resp.json()
extract = data.get("extract", "")
page_title = data.get("title", title)
description = data.get("description", "")
result = f"{page_title}"
if description:
result += f" ({description})"
result += f": {extract}" if extract else ": No summary available"
return result[:2000]
except Exception as e:
return f"Wikipedia lookup failed: {e}"
# --- Main interface ---
async def ask(self, question: str, conversation_context: list[dict] | None = None) -> dict:
"""Host asks intern a direct question. Returns {text, sources, tool_calls}."""
messages = []
# Include recent conversation for context
if conversation_context:
context_text = "\n".join(
f"{msg['role']}: {msg['content']}"
for msg in conversation_context[-6:]
)
messages.append({
"role": "system",
"content": f"CURRENT ON-AIR CONVERSATION:\n{context_text}"
})
messages.append({"role": "user", "content": question})
text, tool_calls = await llm_service.generate_with_tools(
messages=messages,
tools=INTERN_TOOLS,
tool_executor=self._execute_tool,
system_prompt=INTERN_SYSTEM_PROMPT,
model=self.model,
max_tokens=300,
max_tool_rounds=3,
)
# Clean up for TTS
text = self._clean_for_tts(text)
# Log the lookup
if tool_calls:
entry = {
"question": question,
"answer": text[:200],
"tools_used": [tc["name"] for tc in tool_calls],
"timestamp": time.time(),
}
self.lookup_history.append(entry)
self._save()
return {
"text": text,
"sources": [tc["name"] for tc in tool_calls],
"tool_calls": tool_calls,
}
async def interject(self, conversation: list[dict]) -> dict | None:
"""Intern looks at conversation and decides if there's something worth adding.
Returns {text, sources, tool_calls} or None if nothing to add."""
if not conversation or len(conversation) < 2:
return None
context_text = "\n".join(
f"{msg['role']}: {msg['content']}"
for msg in conversation[-8:]
)
messages = [{
"role": "user",
"content": (
f"You're listening to this conversation on the show:\n\n{context_text}\n\n"
"Is there a specific factual claim, question, or topic being discussed "
"that you could quickly look up and add useful info about? "
"If yes, use your tools to research it and give a brief interjection. "
"If there's nothing worth adding, just say exactly: NOTHING_TO_ADD"
),
}]
text, tool_calls = await llm_service.generate_with_tools(
messages=messages,
tools=INTERN_TOOLS,
tool_executor=self._execute_tool,
system_prompt=INTERN_SYSTEM_PROMPT,
model=self.model,
max_tokens=300,
max_tool_rounds=2,
)
text = self._clean_for_tts(text)
if not text or "NOTHING_TO_ADD" in text:
return None
if tool_calls:
entry = {
"question": "(interjection)",
"answer": text[:200],
"tools_used": [tc["name"] for tc in tool_calls],
"timestamp": time.time(),
}
self.lookup_history.append(entry)
self._save()
return {
"text": text,
"sources": [tc["name"] for tc in tool_calls],
"tool_calls": tool_calls,
}
async def monitor_conversation(self, get_conversation: callable, on_suggestion: callable):
"""Background task that watches conversation and buffers suggestions.
get_conversation() should return the current conversation list.
on_suggestion(text, sources) is called when a suggestion is ready."""
last_checked_len = 0
while self.monitoring:
await asyncio.sleep(15)
if not self.monitoring:
break
conversation = get_conversation()
if not conversation or len(conversation) <= last_checked_len:
continue
# Only check if there are new messages since last check
if len(conversation) - last_checked_len < 2:
continue
last_checked_len = len(conversation)
try:
result = await self.interject(conversation)
if result:
self.pending_interjection = result["text"]
self.pending_sources = result.get("tool_calls", [])
await on_suggestion(result["text"], result["sources"])
print(f"[Intern] Buffered suggestion: {result['text'][:60]}...")
except Exception as e:
print(f"[Intern] Monitor error: {e}")
def start_monitoring(self, get_conversation: callable, on_suggestion: callable):
if self.monitoring:
return
self.monitoring = True
self._monitor_task = asyncio.create_task(
self.monitor_conversation(get_conversation, on_suggestion)
)
print("[Intern] Monitoring started")
def stop_monitoring(self):
self.monitoring = False
if self._monitor_task and not self._monitor_task.done():
self._monitor_task.cancel()
self._monitor_task = None
self.pending_interjection = None
self.pending_sources = []
print("[Intern] Monitoring stopped")
def get_pending_suggestion(self) -> dict | None:
if self.pending_interjection:
return {
"text": self.pending_interjection,
"sources": self.pending_sources,
}
return None
def dismiss_suggestion(self):
self.pending_interjection = None
self.pending_sources = []
@staticmethod
def _clean_for_tts(text: str) -> str:
if not text:
return ""
# Remove markdown formatting
text = re.sub(r'\*\*(.+?)\*\*', r'\1', text)
text = re.sub(r'\*(.+?)\*', r'\1', text)
text = re.sub(r'`(.+?)`', r'\1', text)
text = re.sub(r'\[(.+?)\]\(.+?\)', r'\1', text)
# Remove bullet points / list markers
text = re.sub(r'^\s*[-*•]\s+', '', text, flags=re.MULTILINE)
# Collapse whitespace
text = re.sub(r'\s+', ' ', text).strip()
# Remove quotes that TTS reads awkwardly
text = text.replace('"', '').replace('"', '').replace('"', '')
return text
intern_service = InternService()

View File

@@ -1,7 +1,8 @@
"""LLM service with OpenRouter and Ollama support"""
import json
import httpx
from typing import Optional
from typing import Optional, Callable, Awaitable
from ..config import settings
@@ -112,25 +113,156 @@ class LLMService:
self,
messages: list[dict],
system_prompt: Optional[str] = None,
max_tokens: Optional[int] = None
max_tokens: Optional[int] = None,
response_format: Optional[dict] = None
) -> str:
if system_prompt:
messages = [{"role": "system", "content": system_prompt}] + messages
if self.provider == "openrouter":
return await self._call_openrouter_with_fallback(messages, max_tokens=max_tokens)
return await self._call_openrouter_with_fallback(messages, max_tokens=max_tokens, response_format=response_format)
else:
return await self._call_ollama(messages, max_tokens=max_tokens)
async def _call_openrouter_with_fallback(self, messages: list[dict], max_tokens: Optional[int] = None) -> str:
async def generate_with_tools(
self,
messages: list[dict],
tools: list[dict],
tool_executor: Callable[[str, dict], Awaitable[str]],
system_prompt: Optional[str] = None,
model: Optional[str] = None,
max_tokens: int = 500,
max_tool_rounds: int = 3,
) -> tuple[str, list[dict]]:
"""Generate a response with OpenRouter function calling.
Args:
messages: Conversation messages
tools: Tool definitions in OpenAI function-calling format
tool_executor: async function(tool_name, arguments) -> result string
system_prompt: Optional system prompt
model: Model to use (defaults to primary openrouter_model)
max_tokens: Max tokens for response
max_tool_rounds: Max tool call rounds to prevent loops
Returns:
(final_text, tool_calls_made) where tool_calls_made is a list of
{"name": str, "arguments": dict, "result": str} dicts
"""
model = model or self.openrouter_model
msgs = list(messages)
if system_prompt:
msgs = [{"role": "system", "content": system_prompt}] + msgs
all_tool_calls = []
for round_num in range(max_tool_rounds + 1):
payload = {
"model": model,
"messages": msgs,
"max_tokens": max_tokens,
"temperature": 0.65,
"tools": tools,
"tool_choice": "auto",
}
try:
response = await self.client.post(
"https://openrouter.ai/api/v1/chat/completions",
headers={
"Authorization": f"Bearer {settings.openrouter_api_key}",
"Content-Type": "application/json",
},
json=payload,
timeout=15.0,
)
response.raise_for_status()
data = response.json()
except httpx.TimeoutException:
print(f"[LLM-Tools] {model} timed out (round {round_num})")
break
except Exception as e:
print(f"[LLM-Tools] {model} error (round {round_num}): {e}")
break
choice = data["choices"][0]
msg = choice["message"]
# Check for tool calls
tool_calls = msg.get("tool_calls")
if not tool_calls:
# No tool calls — LLM returned a final text response
content = msg.get("content", "")
return content or "", all_tool_calls
# Append assistant message with tool calls to conversation
msgs.append(msg)
# Execute each tool call
for tc in tool_calls:
func = tc["function"]
tool_name = func["name"]
try:
arguments = json.loads(func["arguments"])
except (json.JSONDecodeError, TypeError):
arguments = {}
print(f"[LLM-Tools] Round {round_num}: calling {tool_name}({arguments})")
try:
result = await tool_executor(tool_name, arguments)
except Exception as e:
result = f"Error: {e}"
print(f"[LLM-Tools] Tool {tool_name} failed: {e}")
all_tool_calls.append({
"name": tool_name,
"arguments": arguments,
"result": result[:500],
})
# Append tool result to conversation
msgs.append({
"role": "tool",
"tool_call_id": tc["id"],
"content": result,
})
# Exhausted tool rounds or hit an error — do one final call without tools
print(f"[LLM-Tools] Finishing after {len(all_tool_calls)} tool calls")
try:
final_payload = {
"model": model,
"messages": msgs,
"max_tokens": max_tokens,
"temperature": 0.65,
}
response = await self.client.post(
"https://openrouter.ai/api/v1/chat/completions",
headers={
"Authorization": f"Bearer {settings.openrouter_api_key}",
"Content-Type": "application/json",
},
json=final_payload,
timeout=15.0,
)
response.raise_for_status()
data = response.json()
content = data["choices"][0]["message"].get("content", "")
return content or "", all_tool_calls
except Exception as e:
print(f"[LLM-Tools] Final call failed: {e}")
return "", all_tool_calls
async def _call_openrouter_with_fallback(self, messages: list[dict], max_tokens: Optional[int] = None, response_format: Optional[dict] = None) -> str:
"""Try primary model, then fallback models. Always returns a response."""
# Try primary model first
result = await self._call_openrouter_once(messages, self.openrouter_model, max_tokens=max_tokens)
result = await self._call_openrouter_once(messages, self.openrouter_model, max_tokens=max_tokens, response_format=response_format)
if result is not None:
return result
# Try fallback models
# Try fallback models (drop response_format for fallbacks — not all models support it)
for model in FALLBACK_MODELS:
if model == self.openrouter_model:
continue # Already tried
@@ -143,16 +275,10 @@ class LLMService:
print("[LLM] All models failed, using canned response")
return "Sorry, I totally blanked out for a second. What were you saying?"
async def _call_openrouter_once(self, messages: list[dict], model: str, timeout: float = 10.0, max_tokens: Optional[int] = None) -> str | None:
async def _call_openrouter_once(self, messages: list[dict], model: str, timeout: float = 10.0, max_tokens: Optional[int] = None, response_format: Optional[dict] = None) -> str | None:
"""Single attempt to call OpenRouter. Returns None on failure (not a fallback string)."""
try:
response = await self.client.post(
"https://openrouter.ai/api/v1/chat/completions",
headers={
"Authorization": f"Bearer {settings.openrouter_api_key}",
"Content-Type": "application/json",
},
json={
payload = {
"model": model,
"messages": messages,
"max_tokens": max_tokens or 500,
@@ -160,7 +286,16 @@ class LLMService:
"top_p": 0.9,
"frequency_penalty": 0.3,
"presence_penalty": 0.15,
}
if response_format:
payload["response_format"] = response_format
response = await self.client.post(
"https://openrouter.ai/api/v1/chat/completions",
headers={
"Authorization": f"Bearer {settings.openrouter_api_key}",
"Content-Type": "application/json",
},
json=payload,
timeout=timeout,
)
response.raise_for_status()

View File

@@ -52,7 +52,8 @@ class RegularCallerService:
def add_regular(self, name: str, gender: str, age: int, job: str,
location: str, personality_traits: list[str],
first_call_summary: str, voice: str = None,
stable_seeds: dict = None) -> dict:
stable_seeds: dict = None,
structured_background: dict = None) -> dict:
"""Promote a first-time caller to regular"""
# Retire oldest if at cap
if len(self._regulars) >= MAX_REGULARS:
@@ -70,8 +71,11 @@ class RegularCallerService:
"personality_traits": personality_traits,
"voice": voice,
"stable_seeds": stable_seeds or {},
"structured_background": structured_background,
"relationships": {},
"call_history": [
{"summary": first_call_summary, "timestamp": time.time()}
{"summary": first_call_summary, "timestamp": time.time(),
"arc_status": "ongoing"}
],
"last_call": time.time(),
"created_at": time.time(),
@@ -81,18 +85,37 @@ class RegularCallerService:
print(f"[Regulars] Promoted {name} to regular (total: {len(self._regulars)})")
return regular
def update_after_call(self, regular_id: str, call_summary: str):
def update_after_call(self, regular_id: str, call_summary: str,
key_moments: list = None, arc_status: str = "ongoing"):
"""Update a regular's history after a returning call"""
for regular in self._regulars:
if regular["id"] == regular_id:
regular.setdefault("call_history", []).append(
{"summary": call_summary, "timestamp": time.time()}
)
entry = {
"summary": call_summary,
"timestamp": time.time(),
"arc_status": arc_status,
}
if key_moments:
entry["key_moments"] = key_moments
regular.setdefault("call_history", []).append(entry)
regular["last_call"] = time.time()
self._save()
print(f"[Regulars] Updated {regular['name']} call history ({len(regular['call_history'])} calls)")
return
print(f"[Regulars] Regular {regular_id} not found for update")
def add_relationship(self, regular_id: str, other_name: str,
rel_type: str, context: str):
"""Track a relationship between regulars"""
for regular in self._regulars:
if regular["id"] == regular_id:
regular.setdefault("relationships", {})[other_name] = {
"type": rel_type,
"context": context,
}
self._save()
print(f"[Regulars] {regular['name']}{other_name}: {rel_type}")
return
regular_caller_service = RegularCallerService()

View File

@@ -130,6 +130,89 @@ INWORLD_SPEED_OVERRIDES = {
}
DEFAULT_INWORLD_SPEED = 1.1 # Slight bump for all voices
# Voice profiles — perceptual dimensions for each Inworld voice.
# Used by style-to-voice matching to pair caller personalities with fitting voices.
# weight: vocal depth/richness (light, medium, heavy)
# energy: default speaking animation (low, medium, high)
# warmth: friendliness/openness in the voice (cool, neutral, warm)
# age_feel: perceived speaker age (young, middle, mature)
VOICE_PROFILES = {
# --- Male voices ---
# Known characterizations from INWORLD_VOICES mapping and usage
"Alex": {"weight": "light", "energy": "high", "warmth": "warm", "age_feel": "young"}, # energetic, expressive, mildly nasal
"Edward": {"weight": "medium", "energy": "high", "warmth": "neutral", "age_feel": "middle"}, # fast-talking, emphatic, streetwise
"Shaun": {"weight": "medium", "energy": "high", "warmth": "warm", "age_feel": "middle"}, # friendly, dynamic, conversational
"Craig": {"weight": "heavy", "energy": "low", "warmth": "cool", "age_feel": "mature"}, # older British, refined, articulate
"Timothy": {"weight": "light", "energy": "high", "warmth": "warm", "age_feel": "young"}, # lively, upbeat American
"Dennis": {"weight": "medium", "energy": "high", "warmth": "warm", "age_feel": "middle"}, # energetic, default voice
"Ronald": {"weight": "heavy", "energy": "medium", "warmth": "neutral", "age_feel": "mature"}, # gruff, authoritative
"Theodore": {"weight": "heavy", "energy": "low", "warmth": "warm", "age_feel": "mature"}, # slow, deliberate
"Blake": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
"Carter": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
"Clive": {"weight": "heavy", "energy": "low", "warmth": "cool", "age_feel": "mature"},
"Mark": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
"Sebastian": {"weight": "medium", "energy": "medium", "warmth": "cool", "age_feel": "middle"}, # used by Silas (cult leader) & Chip
"Elliot": {"weight": "light", "energy": "medium", "warmth": "warm", "age_feel": "young"}, # used by Otis (comedian)
# Remaining male pool voices
"Arjun": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"},
"Brian": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"},
"Callum": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "young"},
"Derek": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
"Ethan": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "young"},
"Evan": {"weight": "light", "energy": "medium", "warmth": "neutral", "age_feel": "young"},
"Gareth": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
"Graham": {"weight": "heavy", "energy": "low", "warmth": "neutral", "age_feel": "mature"},
"Grant": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
"Hades": {"weight": "heavy", "energy": "low", "warmth": "cool", "age_feel": "mature"},
"Hamish": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"},
"Hank": {"weight": "heavy", "energy": "medium", "warmth": "warm", "age_feel": "mature"},
"Jake": {"weight": "medium", "energy": "high", "warmth": "warm", "age_feel": "young"},
"James": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
"Jason": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
"Liam": {"weight": "medium", "energy": "high", "warmth": "warm", "age_feel": "young"},
"Malcolm": {"weight": "heavy", "energy": "low", "warmth": "cool", "age_feel": "mature"},
"Mortimer": {"weight": "heavy", "energy": "low", "warmth": "cool", "age_feel": "mature"},
"Nate": {"weight": "light", "energy": "high", "warmth": "warm", "age_feel": "young"},
"Oliver": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"},
"Rupert": {"weight": "medium", "energy": "low", "warmth": "cool", "age_feel": "mature"},
"Simon": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
"Tyler": {"weight": "light", "energy": "high", "warmth": "neutral", "age_feel": "young"},
"Victor": {"weight": "heavy", "energy": "medium", "warmth": "cool", "age_feel": "mature"},
"Vinny": {"weight": "medium", "energy": "high", "warmth": "warm", "age_feel": "middle"},
# --- Female voices ---
# Known characterizations
"Hana": {"weight": "light", "energy": "high", "warmth": "warm", "age_feel": "young"}, # bright, expressive young
"Ashley": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"}, # warm, natural
"Wendy": {"weight": "medium", "energy": "low", "warmth": "cool", "age_feel": "mature"}, # posh, middle-aged British
"Sarah": {"weight": "light", "energy": "high", "warmth": "neutral", "age_feel": "middle"}, # fast-talking, questioning
"Deborah": {"weight": "medium", "energy": "low", "warmth": "warm", "age_feel": "mature"}, # gentle, elegant
"Olivia": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"},
"Julia": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"}, # used by Angie (deadpan)
"Priya": {"weight": "light", "energy": "medium", "warmth": "warm", "age_feel": "young"},
"Amina": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"}, # used by Charlene (bragger)
"Tessa": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"}, # used by Lucille
"Kelsey": {"weight": "light", "energy": "medium", "warmth": "neutral", "age_feel": "young"}, # used by Maxine (quiet/nervous)
# Remaining female pool voices
"Anjali": {"weight": "light", "energy": "medium", "warmth": "warm", "age_feel": "young"},
"Celeste": {"weight": "light", "energy": "medium", "warmth": "cool", "age_feel": "middle"},
"Chloe": {"weight": "light", "energy": "high", "warmth": "warm", "age_feel": "young"},
"Claire": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
"Darlene": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "mature"},
"Elizabeth": {"weight": "medium", "energy": "medium", "warmth": "cool", "age_feel": "mature"},
"Jessica": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"},
"Kayla": {"weight": "light", "energy": "high", "warmth": "warm", "age_feel": "young"},
"Lauren": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
"Loretta": {"weight": "medium", "energy": "low", "warmth": "warm", "age_feel": "mature"},
"Luna": {"weight": "light", "energy": "medium", "warmth": "warm", "age_feel": "young"},
"Marlene": {"weight": "medium", "energy": "low", "warmth": "neutral", "age_feel": "mature"},
"Miranda": {"weight": "medium", "energy": "medium", "warmth": "cool", "age_feel": "middle"},
"Pippa": {"weight": "light", "energy": "high", "warmth": "warm", "age_feel": "young"},
"Saanvi": {"weight": "light", "energy": "medium", "warmth": "warm", "age_feel": "young"},
"Serena": {"weight": "medium", "energy": "medium", "warmth": "cool", "age_feel": "middle"},
"Veronica": {"weight": "medium", "energy": "medium", "warmth": "cool", "age_feel": "middle"},
"Victoria": {"weight": "medium", "energy": "low", "warmth": "cool", "age_feel": "mature"},
}
def preprocess_text_for_kokoro(text: str) -> str:
"""

View File

@@ -12,6 +12,7 @@
--text-muted: #9a8b78;
--radius: 12px;
--radius-sm: 8px;
--transition: 0.2s ease;
}
* {
@@ -169,6 +170,7 @@ section {
padding: 16px;
border-radius: var(--radius);
border: 1px solid rgba(232, 121, 29, 0.08);
transition: border-color var(--transition), box-shadow var(--transition);
}
section h2 {
@@ -197,6 +199,30 @@ section h2 {
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 5px;
justify-content: center;
position: relative;
}
.energy-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
flex-shrink: 0;
}
.shape-badge {
font-size: 0.6rem;
background: rgba(232, 121, 29, 0.25);
color: var(--accent);
padding: 1px 4px;
border-radius: 3px;
font-weight: bold;
letter-spacing: 0.5px;
flex-shrink: 0;
}
.caller-btn:hover {
@@ -217,8 +243,15 @@ section h2 {
margin-bottom: 12px;
}
/* Call action buttons row */
.call-actions {
display: flex;
gap: 8px;
margin-top: 8px;
}
.hangup-btn {
width: 100%;
flex: 1;
background: var(--accent-red);
color: white;
border: none;
@@ -229,6 +262,103 @@ section h2 {
transition: background 0.2s;
}
.wrapup-btn {
flex: 1;
background: #7a6020;
color: #f0d060;
border: 2px solid #d4a030;
padding: 12px;
border-radius: var(--radius-sm);
cursor: pointer;
font-weight: bold;
transition: all 0.2s;
}
.wrapup-btn:hover:not(:disabled) {
background: #d4a030;
color: #1a1209;
}
.wrapup-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.wrapup-btn.active {
background: #d4a030;
color: #1a1209;
animation: wrapup-pulse 1.5s ease-in-out infinite;
}
@keyframes wrapup-pulse {
0%, 100% { box-shadow: 0 0 8px rgba(212, 160, 48, 0.4); }
50% { box-shadow: 0 0 16px rgba(212, 160, 48, 0.8); }
}
/* Caller info panel */
.caller-info-panel {
background: var(--bg-light);
border: 1px solid rgba(232, 121, 29, 0.15);
border-radius: var(--radius-sm);
padding: 10px 12px;
margin: 8px 0;
}
.caller-info-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 6px;
}
.info-badge {
font-size: 0.75rem;
padding: 2px 8px;
border-radius: 10px;
font-weight: 600;
}
.info-badge.shape {
background: rgba(232, 121, 29, 0.2);
color: var(--accent);
}
.info-badge.energy {
color: white;
font-size: 0.7rem;
}
.info-badge.emotion {
background: rgba(154, 139, 120, 0.2);
color: var(--text-muted);
font-style: italic;
}
.caller-signature {
font-size: 0.8rem;
color: var(--accent);
margin-bottom: 4px;
font-style: italic;
}
.caller-situation {
font-size: 0.8rem;
color: var(--text-muted);
line-height: 1.3;
}
.caller-background-full {
margin-top: 8px;
font-size: 0.75rem;
color: var(--text-muted);
}
.caller-background-full summary {
cursor: pointer;
color: var(--text-muted);
font-size: 0.7rem;
}
.hangup-btn:hover {
background: #e03030;
}
@@ -399,10 +529,11 @@ section h2 {
}
}
.soundboard {
.soundboard-pinned {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 8px;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin-bottom: 10px;
}
.sound-btn {
@@ -416,6 +547,43 @@ section h2 {
transition: all 0.1s;
}
.sound-btn.pinned {
padding: 18px 12px;
font-size: 1rem;
font-weight: 700;
border-width: 2px;
}
.sound-btn.pin-cheer {
border-color: #5a8a3c;
color: #7abf52;
}
.sound-btn.pin-cheer:hover {
background: #5a8a3c;
border-color: #5a8a3c;
color: #fff;
}
.sound-btn.pin-applause {
border-color: var(--accent);
color: var(--accent);
}
.sound-btn.pin-applause:hover {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.sound-btn.pin-boo {
border-color: var(--accent-red);
color: #e85050;
}
.sound-btn.pin-boo:hover {
background: var(--accent-red);
border-color: var(--accent-red);
color: #fff;
}
.sound-btn:hover {
background: var(--accent);
border-color: var(--accent);
@@ -426,6 +594,57 @@ section h2 {
transform: scale(0.95);
}
.soundboard-toggle {
width: 100%;
background: none;
border: 1px solid rgba(232, 121, 29, 0.1);
border-radius: var(--radius-sm);
color: var(--text-muted);
padding: 8px;
cursor: pointer;
font-size: 0.8rem;
margin-bottom: 10px;
transition: all 0.2s;
}
.soundboard-toggle:hover {
color: var(--text);
border-color: rgba(232, 121, 29, 0.3);
}
.toggle-arrow {
font-size: 0.7rem;
margin-left: 4px;
}
.soundboard-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 8px;
}
/* Keyboard shortcut labels */
.shortcut-label {
display: inline-block;
font-size: 0.6rem;
color: var(--text-muted);
background: rgba(232, 121, 29, 0.08);
border: 1px solid rgba(232, 121, 29, 0.12);
border-radius: 3px;
padding: 1px 4px;
margin-left: 6px;
font-family: 'Monaco', 'Menlo', monospace;
vertical-align: middle;
opacity: 0.7;
}
.caller-btn .shortcut-label {
display: block;
margin: 3px auto 0;
margin-left: auto;
width: fit-content;
}
/* Modal */
.modal {
position: fixed;
@@ -776,3 +995,213 @@ section h2 {
.email-preview { font-size: 0.8rem; color: var(--text-muted); line-height: 1.3; }
.email-item .vm-actions { margin-top: 0.25rem; }
.email-body-expanded { margin-top: 0.4rem; padding: 0.5rem; background: rgba(232, 121, 29, 0.08); border-radius: var(--radius-sm); font-size: 0.85rem; line-height: 1.5; white-space: pre-wrap; max-height: 200px; overflow-y: auto; }
/* === Visual Polish === */
/* 1. Thinking pulse on chat when waiting for AI */
@keyframes thinking-pulse {
0%, 100% { border-color: rgba(232, 121, 29, 0.06); }
50% { border-color: rgba(232, 121, 29, 0.3); }
}
.chat-log.thinking {
animation: thinking-pulse 1.5s ease-in-out infinite;
}
/* 3 & 5. Active call section glow + chat highlight when call is live */
.callers-section.call-active {
border-color: rgba(232, 121, 29, 0.35);
box-shadow: 0 0 16px rgba(232, 121, 29, 0.1);
}
.chat-section.call-active {
border-color: rgba(232, 121, 29, 0.25);
box-shadow: 0 0 12px rgba(232, 121, 29, 0.06);
}
/* 7. Compact media row — Music / Ads / Idents side by side */
.media-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
grid-column: span 2;
}
@media (max-width: 700px) {
.media-row {
grid-template-columns: 1fr;
grid-column: span 1;
}
}
.media-row .music-section {
padding: 12px;
}
.media-row .music-section h2 {
font-size: 0.75rem;
margin-bottom: 8px;
}
.media-row .music-section select {
padding: 6px 8px;
font-size: 0.8rem;
margin-bottom: 6px;
}
.media-row .music-controls {
gap: 4px;
}
.media-row .music-controls button {
padding: 6px 10px;
font-size: 0.8rem;
}
/* Devon (Intern) */
.message.devon {
border-left: 3px solid #4ab5a0;
padding-left: 0.5rem;
background: rgba(74, 181, 160, 0.06);
}
.message.devon strong {
color: #4ab5a0;
}
.devon-bar {
margin-bottom: 10px;
}
.devon-ask-row {
display: flex;
gap: 6px;
align-items: center;
}
.devon-input {
flex: 1;
padding: 8px 10px;
background: var(--bg);
color: var(--text);
border: 1px solid rgba(74, 181, 160, 0.2);
border-radius: var(--radius-sm);
font-size: 0.85rem;
}
.devon-input:focus {
outline: none;
border-color: #4ab5a0;
}
.devon-input::placeholder {
color: var(--text-muted);
}
.devon-ask-btn {
background: #4ab5a0;
color: #fff;
border: none;
padding: 8px 14px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.85rem;
font-weight: 600;
transition: background 0.2s;
white-space: nowrap;
}
.devon-ask-btn:hover {
background: #5cc5b0;
}
.devon-interject-btn {
background: var(--bg);
color: #4ab5a0;
border: 1px solid rgba(74, 181, 160, 0.25);
padding: 8px 10px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.8rem;
transition: all 0.2s;
white-space: nowrap;
}
.devon-interject-btn:hover {
border-color: #4ab5a0;
background: rgba(74, 181, 160, 0.1);
}
.devon-monitor-label {
display: flex;
align-items: center;
gap: 4px;
font-size: 0.75rem;
color: var(--text-muted);
cursor: pointer;
white-space: nowrap;
}
.devon-monitor-label input[type="checkbox"] {
accent-color: #4ab5a0;
}
.devon-suggestion {
display: flex;
align-items: center;
gap: 8px;
margin-top: 6px;
padding: 8px 12px;
background: rgba(74, 181, 160, 0.08);
border: 1px solid rgba(74, 181, 160, 0.25);
border-radius: var(--radius-sm);
animation: devon-pulse 2s ease-in-out infinite;
}
.devon-suggestion.hidden {
display: none !important;
}
@keyframes devon-pulse {
0%, 100% { border-color: rgba(74, 181, 160, 0.25); }
50% { border-color: rgba(74, 181, 160, 0.6); }
}
.devon-suggestion-text {
flex: 1;
font-size: 0.85rem;
color: #4ab5a0;
font-weight: 600;
}
.devon-play-btn {
background: #4ab5a0;
color: #fff;
border: none;
padding: 4px 12px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.8rem;
font-weight: 600;
transition: background 0.2s;
}
.devon-play-btn:hover {
background: #5cc5b0;
}
.devon-dismiss-btn {
background: none;
color: var(--text-muted);
border: 1px solid rgba(232, 121, 29, 0.15);
padding: 4px 10px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.8rem;
transition: all 0.2s;
}
.devon-dismiss-btn:hover {
color: var(--text);
border-color: rgba(232, 121, 29, 0.3);
}

View File

@@ -50,11 +50,23 @@
</label>
</div>
<div id="call-status" class="call-status">No active call</div>
<details id="caller-background-details" class="caller-background hidden">
<summary>Caller Background</summary>
<div id="caller-info-panel" class="caller-info-panel hidden">
<div class="caller-info-row">
<span id="caller-shape-badge" class="info-badge shape"></span>
<span id="caller-energy-badge" class="info-badge energy"></span>
<span id="caller-emotion" class="info-badge emotion"></span>
</div>
<div id="caller-signature" class="caller-signature"></div>
<div id="caller-situation" class="caller-situation"></div>
<details id="caller-background-details" class="caller-background-full">
<summary>Full Background</summary>
<div id="caller-background"></div>
</details>
<button id="hangup-btn" class="hangup-btn" disabled>Hang Up</button>
</div>
<div class="call-actions">
<button id="wrapup-btn" class="wrapup-btn" disabled>Wrap It Up <span class="shortcut-label">W</span></button>
<button id="hangup-btn" class="hangup-btn" disabled>Hang Up <span class="shortcut-label">H</span></button>
</div>
</section>
<!-- Call Queue -->
@@ -84,6 +96,21 @@
<!-- Chat -->
<section class="chat-section">
<div id="chat" class="chat-log"></div>
<div class="devon-bar">
<div class="devon-ask-row">
<input type="text" id="devon-input" placeholder="Ask Devon..." class="devon-input">
<button id="devon-ask-btn" class="devon-ask-btn">Ask <span class="shortcut-label">D</span></button>
<button id="devon-interject-btn" class="devon-interject-btn" title="Devon interjects on current conversation">Interject</button>
<label class="devon-monitor-label" title="Devon auto-monitors conversations">
<input type="checkbox" id="devon-monitor" checked> Monitor
</label>
</div>
<div id="devon-suggestion" class="devon-suggestion hidden">
<span class="devon-suggestion-text">Devon has something</span>
<button id="devon-play-btn" class="devon-play-btn">Play</button>
<button id="devon-dismiss-btn" class="devon-dismiss-btn">Dismiss</button>
</div>
</div>
<div class="talk-controls">
<button id="talk-btn" class="talk-btn">Hold to Talk</button>
<button id="type-btn" class="type-btn">Type</button>
@@ -91,18 +118,18 @@
<div id="status" class="status hidden"></div>
</section>
<!-- Music -->
<!-- Music / Ads / Idents -->
<div class="media-row">
<section class="music-section">
<h2>Music</h2>
<select id="track-select"></select>
<div class="music-controls">
<button id="play-btn">Play</button>
<button id="play-btn">Play <span class="shortcut-label">M</span></button>
<button id="stop-btn">Stop</button>
<input type="range" id="volume" min="0" max="100" value="30">
</div>
</section>
<!-- Ads -->
<section class="music-section">
<h2>Ads</h2>
<select id="ad-select"></select>
@@ -112,7 +139,6 @@
</div>
</section>
<!-- Idents -->
<section class="music-section">
<h2>Idents</h2>
<select id="ident-select"></select>
@@ -121,6 +147,7 @@
<button id="ident-stop-btn">Stop</button>
</div>
</section>
</div>
<!-- Sound Effects -->
<section class="sounds-section">
@@ -251,6 +278,6 @@
</div>
</div>
<script src="/js/app.js?v=18"></script>
<script src="/js/app.js?v=20"></script>
</body>
</html>

View File

@@ -14,6 +14,8 @@ let lastLogCount = 0;
// Track lists
let tracks = [];
let sounds = [];
let isMusicPlaying = false;
let soundboardExpanded = false;
// --- Helpers ---
@@ -165,6 +167,49 @@ function initEventListeners() {
stopRecording();
});
// Keyboard shortcuts
document.addEventListener('keydown', e => {
if (_isTyping()) return;
// Don't fire shortcuts when a modal is open (except Escape to close it)
const modalOpen = document.querySelector('.modal:not(.hidden)');
if (e.key === 'Escape') {
if (modalOpen) {
modalOpen.classList.add('hidden');
e.preventDefault();
}
return;
}
if (modalOpen) return;
const key = e.key.toLowerCase();
// 1-9, 0: Start call with caller in that slot
if (/^[0-9]$/.test(key)) {
e.preventDefault();
const idx = key === '0' ? 9 : parseInt(key) - 1;
const btns = document.querySelectorAll('.caller-btn');
if (btns[idx]) btns[idx].click();
return;
}
switch (key) {
case 'h':
e.preventDefault();
hangup();
break;
case 'w':
e.preventDefault();
wrapUp();
break;
case 'm':
e.preventDefault();
toggleMusic();
break;
case 'd':
e.preventDefault();
document.getElementById('devon-input')?.focus();
break;
}
});
// Type button
document.getElementById('type-btn')?.addEventListener('click', () => {
document.getElementById('type-modal')?.classList.remove('hidden');
@@ -194,6 +239,31 @@ function initEventListeners() {
document.getElementById('ident-play-btn')?.addEventListener('click', playIdent);
document.getElementById('ident-stop-btn')?.addEventListener('click', stopIdent);
// Devon (Intern)
document.getElementById('devon-ask-btn')?.addEventListener('click', () => {
const input = document.getElementById('devon-input');
if (input?.value.trim()) {
askDevon(input.value.trim());
input.value = '';
}
});
document.getElementById('devon-input')?.addEventListener('keydown', e => {
if (e.key === 'Enter') {
e.preventDefault();
const input = e.target;
if (input.value.trim()) {
askDevon(input.value.trim());
input.value = '';
}
}
});
document.getElementById('devon-interject-btn')?.addEventListener('click', interjectDevon);
document.getElementById('devon-monitor')?.addEventListener('change', e => {
toggleInternMonitor(e.target.checked);
});
document.getElementById('devon-play-btn')?.addEventListener('click', playDevonSuggestion);
document.getElementById('devon-dismiss-btn')?.addEventListener('click', dismissDevonSuggestion);
// Settings
document.getElementById('settings-btn')?.addEventListener('click', async () => {
document.getElementById('settings-modal')?.classList.remove('hidden');
@@ -209,6 +279,9 @@ function initEventListeners() {
});
document.getElementById('refresh-ollama')?.addEventListener('click', refreshOllamaModels);
// Wrap-up button
document.getElementById('wrapup-btn')?.addEventListener('click', wrapUp);
// Real caller hangup
document.getElementById('hangup-real-btn')?.addEventListener('click', async () => {
await fetch('/api/hangup/real', { method: 'POST' });
@@ -413,12 +486,34 @@ async function loadCallers() {
if (!grid) return;
grid.innerHTML = '';
data.callers.forEach(caller => {
data.callers.forEach((caller, idx) => {
const btn = document.createElement('button');
btn.className = 'caller-btn';
if (caller.returning) btn.classList.add('returning');
btn.textContent = caller.returning ? `\u2605 ${caller.name}` : caller.name;
btn.dataset.key = caller.key;
let html = '';
if (caller.energy_level) {
const energyColors = { low: '#4a7ab5', medium: '#5a8a3c', high: '#e8791d', very_high: '#cc2222' };
const color = energyColors[caller.energy_level] || '#9a8b78';
html += `<span class="energy-dot" style="background:${color}" title="${caller.energy_level} energy"></span>`;
}
html += caller.returning ? `<span class="caller-name">\u2605 ${caller.name}</span>` : `<span class="caller-name">${caller.name}</span>`;
if (caller.call_shape && caller.call_shape !== 'standard') {
const shapeLabels = {
escalating_reveal: 'ER', am_i_the_asshole: 'AITA', confrontation: 'VS',
celebration: '\u{1F389}', quick_hit: 'QH', bait_and_switch: 'B&S',
the_hangup: 'HU', reactive: 'RE'
};
const label = shapeLabels[caller.call_shape] || caller.call_shape.substring(0, 2).toUpperCase();
html += `<span class="shape-badge" title="${caller.call_shape.replace(/_/g, ' ')}">${label}</span>`;
}
// Shortcut label: 1-9 for first 9, 0 for 10th
if (idx < 10) {
const shortcutKey = idx === 9 ? '0' : String(idx + 1);
html += `<span class="shortcut-label">${shortcutKey}</span>`;
}
btn.innerHTML = html;
btn.addEventListener('click', () => startCall(caller.key, caller.name));
grid.appendChild(btn);
});
@@ -443,6 +538,8 @@ async function startCall(key, name) {
const data = await res.json();
currentCaller = { key, name };
document.querySelector('.callers-section')?.classList.add('call-active');
document.querySelector('.chat-section')?.classList.add('call-active');
// Check if real caller is active (three-way scenario)
const realCallerActive = document.getElementById('real-caller-info') &&
@@ -455,6 +552,8 @@ async function startCall(key, name) {
}
document.getElementById('hangup-btn').disabled = false;
const wrapupBtn = document.getElementById('wrapup-btn');
if (wrapupBtn) { wrapupBtn.disabled = false; wrapupBtn.classList.remove('active'); }
// Show AI caller in active call indicator
const aiInfo = document.getElementById('ai-caller-info');
@@ -462,13 +561,25 @@ async function startCall(key, name) {
if (aiInfo) aiInfo.classList.remove('hidden');
if (aiName) aiName.textContent = name;
// Show caller background in disclosure triangle
const bgDetails = document.getElementById('caller-background-details');
const bgEl = document.getElementById('caller-background');
if (bgDetails && bgEl && data.background) {
bgEl.textContent = data.background;
bgDetails.classList.remove('hidden');
// Show caller info panel with structured data
const infoPanel = document.getElementById('caller-info-panel');
if (infoPanel && data.caller_info) {
const ci = data.caller_info;
const energyColors = { low: '#4a7ab5', medium: '#5a8a3c', high: '#e8791d', very_high: '#cc2222' };
const shapeBadge = document.getElementById('caller-shape-badge');
const energyBadge = document.getElementById('caller-energy-badge');
const emotionBadge = document.getElementById('caller-emotion');
const signature = document.getElementById('caller-signature');
const situation = document.getElementById('caller-situation');
if (shapeBadge) shapeBadge.textContent = (ci.call_shape || 'standard').replace(/_/g, ' ');
if (energyBadge) { energyBadge.textContent = (ci.energy_level || '').replace('_', ' '); energyBadge.style.background = energyColors[ci.energy_level] || '#9a8b78'; }
if (emotionBadge) emotionBadge.textContent = ci.emotional_state || '';
if (signature) signature.textContent = ci.signature_detail ? `"${ci.signature_detail}"` : '';
if (situation) situation.textContent = ci.situation_summary || '';
infoPanel.classList.remove('hidden');
}
const bgEl = document.getElementById('caller-background');
if (bgEl && data.background) bgEl.textContent = data.background;
document.querySelectorAll('.caller-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.key === key);
@@ -512,12 +623,17 @@ async function hangup() {
currentCaller = null;
isProcessing = false;
hideStatus();
document.querySelector('.callers-section')?.classList.remove('call-active');
document.querySelector('.chat-section')?.classList.remove('call-active');
document.getElementById('call-status').textContent = 'No active call';
document.getElementById('hangup-btn').disabled = true;
const wrapBtn = document.getElementById('wrapup-btn');
if (wrapBtn) { wrapBtn.disabled = true; wrapBtn.classList.remove('active'); }
document.querySelectorAll('.caller-btn').forEach(btn => btn.classList.remove('active'));
// Hide caller background
// Hide caller info panel and background
document.getElementById('caller-info-panel')?.classList.add('hidden');
const bgDetails2 = document.getElementById('caller-background-details');
if (bgDetails2) bgDetails2.classList.add('hidden');
@@ -527,6 +643,26 @@ async function hangup() {
}
async function wrapUp() {
if (!currentCaller) return;
try {
await fetch('/api/wrap-up', { method: 'POST' });
const wrapupBtn = document.getElementById('wrapup-btn');
if (wrapupBtn) wrapupBtn.classList.add('active');
log(`Wrapping up ${currentCaller.name}...`);
} catch (err) {
console.error('wrapUp error:', err);
}
}
function toggleMusic() {
if (isMusicPlaying) {
stopMusic();
} else {
playMusic();
}
}
// --- Server-Side Recording ---
async function startRecording() {
if (!currentCaller || isProcessing) return;
@@ -722,11 +858,13 @@ async function playMusic() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ track, action: 'play' })
});
isMusicPlaying = true;
}
async function stopMusic() {
await fetch('/api/music/stop', { method: 'POST' });
isMusicPlaying = false;
}
@@ -845,14 +983,60 @@ async function loadSounds() {
if (!board) return;
board.innerHTML = '';
const pinnedNames = ['cheer', 'applause', 'boo'];
const pinned = [];
const rest = [];
sounds.forEach(sound => {
const lower = (sound.name || sound.file || '').toLowerCase();
if (pinnedNames.some(p => lower.includes(p))) {
pinned.push(sound);
} else {
rest.push(sound);
}
});
// Pinned buttons — always visible
const pinnedRow = document.createElement('div');
pinnedRow.className = 'soundboard-pinned';
pinned.forEach(sound => {
const btn = document.createElement('button');
btn.className = 'sound-btn pinned';
const lower = (sound.name || sound.file || '').toLowerCase();
if (lower.includes('cheer')) btn.classList.add('pin-cheer');
else if (lower.includes('applause')) btn.classList.add('pin-applause');
else if (lower.includes('boo')) btn.classList.add('pin-boo');
btn.textContent = sound.name;
btn.addEventListener('click', () => playSFX(sound.file));
pinnedRow.appendChild(btn);
});
board.appendChild(pinnedRow);
// Collapsible section for remaining sounds
if (rest.length > 0) {
const toggle = document.createElement('button');
toggle.className = 'soundboard-toggle';
toggle.innerHTML = 'More Sounds <span class="toggle-arrow">&#9660;</span>';
toggle.addEventListener('click', () => {
soundboardExpanded = !soundboardExpanded;
grid.classList.toggle('hidden', !soundboardExpanded);
toggle.querySelector('.toggle-arrow').innerHTML = soundboardExpanded ? '&#9650;' : '&#9660;';
});
board.appendChild(toggle);
const grid = document.createElement('div');
grid.className = 'soundboard-grid hidden';
rest.forEach(sound => {
const btn = document.createElement('button');
btn.className = 'sound-btn';
btn.textContent = sound.name;
btn.addEventListener('click', () => playSFX(sound.file));
board.appendChild(btn);
grid.appendChild(btn);
});
console.log('Loaded', sounds.length, 'sounds');
board.appendChild(grid);
}
console.log('Loaded', sounds.length, 'sounds', `(${pinned.length} pinned)`);
} catch (err) {
console.error('loadSounds error:', err);
}
@@ -972,6 +1156,8 @@ function addMessage(sender, text) {
className += ' host';
} else if (sender === 'System') {
className += ' system';
} else if (sender === 'DEVON') {
className += ' devon';
} else if (sender.includes('(caller)') || sender.includes('Caller #')) {
className += ' real-caller';
} else {
@@ -1008,12 +1194,14 @@ function showStatus(text) {
status.textContent = text;
status.classList.remove('hidden');
}
document.getElementById('chat')?.classList.add('thinking');
}
function hideStatus() {
const status = document.getElementById('status');
if (status) status.classList.add('hidden');
document.getElementById('chat')?.classList.remove('thinking');
}
@@ -1298,9 +1486,17 @@ async function fetchConversationUpdates() {
} else if (msg.type === 'caller_queued') {
// Queue poll will pick this up, just ensure it refreshes
fetchQueue();
} else if (msg.type === 'intern_response') {
addMessage('DEVON', msg.text);
} else if (msg.type === 'intern_suggestion') {
showDevonSuggestion(msg.text);
}
}
}
// Check for intern suggestion in polling response
if (data.intern_suggestion) {
showDevonSuggestion(data.intern_suggestion.text);
}
} catch (err) {}
}
@@ -1512,3 +1708,83 @@ async function deleteEmail(id) {
log('Failed to delete email: ' + err.message);
}
}
// --- Devon (Intern) ---
async function askDevon(question) {
addMessage('You', `Devon, ${question}`);
log(`[Devon] Looking up: ${question}`);
try {
const res = await safeFetch('/api/intern/ask', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question }),
});
if (res.text) {
addMessage('DEVON', res.text);
log(`[Devon] Responded (tools: ${(res.sources || []).join(', ') || 'none'})`);
} else {
log('[Devon] No response');
}
} catch (err) {
log('[Devon] Error: ' + err.message);
}
}
async function interjectDevon() {
log('[Devon] Checking for interjection...');
try {
const res = await safeFetch('/api/intern/interject', { method: 'POST' });
if (res.text) {
addMessage('DEVON', res.text);
log('[Devon] Interjected');
} else {
log('[Devon] Nothing to add');
}
} catch (err) {
log('[Devon] Interject error: ' + err.message);
}
}
async function toggleInternMonitor(enabled) {
try {
await safeFetch('/api/intern/monitor', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
});
log(`[Devon] Monitor ${enabled ? 'on' : 'off'}`);
} catch (err) {
log('[Devon] Monitor toggle error: ' + err.message);
}
}
function showDevonSuggestion(text) {
const el = document.getElementById('devon-suggestion');
const textEl = el?.querySelector('.devon-suggestion-text');
if (el && textEl) {
textEl.textContent = text ? `Devon: "${text.substring(0, 60)}${text.length > 60 ? '...' : ''}"` : 'Devon has something';
el.classList.remove('hidden');
}
}
async function playDevonSuggestion() {
try {
const res = await safeFetch('/api/intern/suggestion/play', { method: 'POST' });
if (res.text) {
addMessage('DEVON', res.text);
}
document.getElementById('devon-suggestion')?.classList.add('hidden');
log('[Devon] Played suggestion');
} catch (err) {
log('[Devon] Play suggestion error: ' + err.message);
}
}
async function dismissDevonSuggestion() {
try {
await safeFetch('/api/intern/suggestion/dismiss', { method: 'POST' });
document.getElementById('devon-suggestion')?.classList.add('hidden');
} catch (err) {}
}

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>How It Works — Luke at the Roost</title>
<meta name="description" content="How Luke at the Roost works: AI-generated callers with unique personalities, real phone calls, voice synthesis, multi-stem recording, and automated post-production.">
<meta name="description" content="How Luke at the Roost works: AI-generated callers with structured personalities, comedy-tuned call shapes, a live research intern, voice-personality matching, multi-stem recording, and automated post-production.">
<meta name="theme-color" content="#1a1209">
<link rel="canonical" href="https://lukeattheroost.com/how-it-works">
@@ -79,6 +79,7 @@
<div class="hiw-step-content">
<h3>A Person Is Born</h3>
<p>Every caller starts as a blank slate. The system generates a complete identity: name, age, job, hometown, and personality. Each caller gets a unique speaking style — some ramble, some are blunt, some deflect with humor. They have relationships, vehicles, strong food opinions, nostalgic memories, and reasons for being up this late. They know what they were watching on TV, what errand they ran today, and what song was on the radio before they called.</p>
<p>But it goes deeper than backstory. Every caller is built with a structured call shape — maybe an escalating reveal where they start casual and drop a bombshell halfway through, a bait-and-switch where the real issue isn't what they said at first, or a slow burn that builds to an emotional peak. They have energy levels, emotional states, and signature details — a phrase they keep coming back to, a nervous tic in how they talk, a specific detail that makes the whole thing feel real. And each caller is matched to a voice that fits their personality. A 60-year-old trucker from Lordsburg doesn't sound like a 23-year-old barista from Tucson.</p>
<p>Some callers become regulars. The system tracks returning callers across episodes — they remember past conversations, reference things they talked about before, and their stories evolve over time. You'll hear Leon check in about going back to school, or Shaniqua update you on her situation at work. They're not reset between shows.</p>
<p>And some callers are drunk, high, or flat-out unhinged. They'll call with conspiracy theories about pigeons being government drones, existential crises about whether fish know they're wet, or to confess they accidentally set their kitchen on fire trying to make grilled cheese at 3 AM.</p>
<div class="hiw-detail-grid">
@@ -87,12 +88,12 @@
<span class="hiw-detail-value">160</span>
</div>
<div class="hiw-detail">
<span class="hiw-detail-label">Personality Layers</span>
<span class="hiw-detail-value">300+</span>
<span class="hiw-detail-label">Voice Profiles</span>
<span class="hiw-detail-value">68</span>
</div>
<div class="hiw-detail">
<span class="hiw-detail-label">Towns with Real Knowledge</span>
<span class="hiw-detail-value">55</span>
<span class="hiw-detail-label">Call Shapes</span>
<span class="hiw-detail-value">8 types</span>
</div>
<div class="hiw-detail">
<span class="hiw-detail-label">Returning Regulars</span>
@@ -115,6 +116,7 @@
<div class="hiw-step-content">
<h3>They Have a Reason to Call</h3>
<p>Some callers have a problem — a fight with a neighbor, a situation at work, something weighing on them at 2 AM. Others call to geek out about Severance, argue about poker strategy, or share something they read about quantum physics. The system draws from over 1,000 unique calling reasons across dozens of categories — problems, stories, advice-seeking, gossip, and deep-dive topics. Every caller has a purpose, not just a script.</p>
<p>The whole thing is tuned for comedy. Not "AI tries to be funny" comedy — more like the energy of late-night call-in radio meets stand-up meets the kind of confessions you only hear at 2 AM. Some calls are genuinely heartfelt. Some are absurd. Some start serious and go completely sideways. The system knows how to build a call for comedic timing — when to hold back a detail, when to escalate, when to let the awkward silence do the work. It's not random chaos; it's structured chaos.</p>
<div class="hiw-split-stat">
<div class="hiw-stat">
<span class="hiw-stat-number">70%</span>
@@ -132,7 +134,9 @@
<div class="hiw-step-number">4</div>
<div class="hiw-step-content">
<h3>The Conversation Is Real</h3>
<p>Luke talks to each caller using push-to-talk, just like a real radio show. His voice is transcribed in real time, sent to an AI that responds in character, and then converted to speech using a voice engine — all in a few seconds. The AI doesn't just answer questions; it reacts, gets emotional, goes on tangents, and remembers what was said earlier in the show. Callers even react to previous callers — "Hey Luke, I heard that guy Tony earlier and I got to say, he's full of it." It makes the show feel like a living community, not isolated calls.</p>
<p>Luke talks to each caller using push-to-talk, just like a real radio show. His voice is transcribed in real time, sent to an AI that responds in character, and then converted to speech using a voice engine — all in a few seconds. The AI doesn't just answer questions; it reacts, gets emotional, goes on tangents, and remembers what was said earlier in the show.</p>
<p>Callers don't just exist in isolation — the show tracks what's been discussed and matches callers thematically. If someone just called about a messy divorce, the next caller who references marriage didn't pick that topic randomly. The system scores previous callers by topic overlap and decides whether the new caller should reference them, disagree with them, or build on what they said. It tracks the show's overall energy so the pacing doesn't flatline — a heavy emotional call might be followed by something lighter, and vice versa.</p>
<p>And when a call has run its course, Luke can hit "Wrap It Up" — a signal that tells the caller to wind things down gracefully. Instead of an abrupt hang-up, the caller gets the hint and starts wrapping up their thought, says their goodbyes, and exits naturally. Just like a real radio host giving the "time's up" hand signal through the glass.</p>
</div>
</div>
@@ -154,6 +158,15 @@
<div class="hiw-step">
<div class="hiw-step-number">7</div>
<div class="hiw-step-content">
<h3>Devon the Intern</h3>
<p>Every show needs someone to yell at. Devon is the show's intern — a 23-year-old NMSU grad who's way too eager, occasionally useful, and frequently wrong. He's not a caller; he's a permanent fixture of the show. When Luke needs a fact checked, a topic researched, or someone to blame for a technical issue, Devon's there.</p>
<p>Devon has real tools. He can search the web, pull up news headlines, look things up on Wikipedia, and read articles — all live during the show. When a caller claims that octopuses have three hearts, Devon's already looking it up. Sometimes he interjects on his own when he thinks he has something useful to add. Sometimes he's right. Sometimes Luke tells him to shut up. He monitors conversations in the background and pipes up with suggestions that the host can play or dismiss. He's the kind of intern who tries really hard and occasionally nails it.</p>
</div>
</div>
<div class="hiw-step">
<div class="hiw-step-number">8</div>
<div class="hiw-step-content">
<h3>The Control Room</h3>
<p>The entire show runs through a custom-built control panel. Luke manages callers, plays music and sound effects, runs ads and station idents, monitors the call queue, and controls everything from one screen. Audio is routed across seven independent channels simultaneously — host mic, AI caller voices, live phone audio, music, sound effects, ads, and station idents all on separate tracks. The website shows a live on-air indicator so listeners know when to call in.</p>
@@ -428,7 +441,7 @@
<div class="hiw-steps">
<div class="hiw-step">
<div class="hiw-step-number">8</div>
<div class="hiw-step-number">9</div>
<div class="hiw-step-content">
<h3>Multi-Stem Recording</h3>
<p>During every show, the system records six separate audio stems simultaneously: host microphone, AI caller voices, music, sound effects, ads, and station idents. Each stem is captured as an independent WAV file with sample-accurate alignment. This gives full control over the final mix — like having a recording studio's multitrack session, not just a flat recording.</p>
@@ -454,7 +467,7 @@
</div>
<div class="hiw-step">
<div class="hiw-step-number">9</div>
<div class="hiw-step-number">10</div>
<div class="hiw-step-content">
<h3>Dialog Editing in REAPER</h3>
<p>Before the automated pipeline runs, the raw stems are loaded into REAPER for dialog editing. A custom Lua script analyzes voice tracks to detect silence gaps — the dead air between caller responses, TTS latency pauses, and gaps where Luke is reading the control room. The script strips these silences and ripple-edits all tracks in sync so ads, idents, and music shift with the dialog cuts. Protected regions marked as ads or idents are preserved — the script knows not to remove silence during an ad break even if the voice tracks are quiet. This tightens a raw two-hour session into a focused episode without cutting any content.</p>
@@ -462,7 +475,7 @@
</div>
<div class="hiw-step">
<div class="hiw-step-number">10</div>
<div class="hiw-step-number">11</div>
<div class="hiw-step-content">
<h3>Post-Production Pipeline</h3>
<p>Once the show ends, a 15-step automated pipeline processes the raw stems into a broadcast-ready episode. Ads and sound effects are hard-limited to prevent clipping. The host mic gets a high-pass filter, de-essing, and breath reduction. Voice tracks are compressed — the host gets aggressive spoken-word compression for consistent levels, callers get telephone EQ to sound like real phone calls. All stems are level-matched, music is ducked under dialog and muted during ads, then everything is mixed to stereo with panning and width. A bus compressor glues the final mix together before silence trimming, fades, and EBU R128 loudness normalization.</p>
@@ -488,7 +501,7 @@
</div>
<div class="hiw-step">
<div class="hiw-step-number">11</div>
<div class="hiw-step-number">12</div>
<div class="hiw-step-content">
<h3>Automated Publishing</h3>
<p>A single command takes a finished episode and handles everything: the audio is transcribed using MLX Whisper running on Apple Silicon GPU to generate full-text transcripts, then an LLM analyzes the transcript to write the episode title, description, and chapter markers with timestamps. The episode is uploaded to the podcast server and directly to YouTube with chapters baked into the description. Chapters and transcripts are attached to the RSS metadata, all media is synced to a global CDN, and social posts are pushed to eight platforms — all from one command.</p>
@@ -514,7 +527,7 @@
</div>
<div class="hiw-step">
<div class="hiw-step-number">12</div>
<div class="hiw-step-number">13</div>
<div class="hiw-step-content">
<h3>Automated Social Clips</h3>
<p>No manual editing, no scheduling tools. After each episode, an LLM reads the full transcript and picks the best moments — funny exchanges, wild confessions, heated debates. Each clip is automatically extracted, transcribed with word-level timestamps, then polished by a second LLM pass that fixes punctuation, capitalization, and misheard words while preserving timing. The clips are rendered as vertical video with speaker-labeled captions and the show's branding. A third LLM writes platform-specific descriptions and hashtags. Then clips are uploaded directly to YouTube Shorts and Bluesky via their APIs, and pushed to Instagram Reels, Facebook Reels, Mastodon, Nostr, LinkedIn, Threads, and TikTok — nine platforms, zero manual work.</p>
@@ -540,7 +553,7 @@
</div>
<div class="hiw-step">
<div class="hiw-step-number">13</div>
<div class="hiw-step-number">14</div>
<div class="hiw-step-content">
<h3>Global Distribution</h3>
<p>Episodes are served through a CDN edge network for fast, reliable playback worldwide. The RSS feed is automatically updated and picked up by Spotify, Apple Podcasts, YouTube, and every other podcast app. The website pulls the live feed to show episodes with embedded playback, full transcripts, and chapter navigation — all served through Cloudflare with edge caching. From recording to available on every platform, the whole pipeline is automated end-to-end.</p>
@@ -597,7 +610,7 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</div>
<h3>They Listen to Each Other</h3>
<p>Callers aren't isolated — they hear what happened earlier in the show. A caller might disagree with the last guy, back someone up, or call in specifically because of something another caller said. The show builds on itself.</p>
<p>Callers aren't isolated — the system matches callers thematically to what's already been discussed. A caller might disagree with the last guy, back someone up, or call in because something another caller said hit close to home. The show tracks energy and pacing so conversations build naturally, not randomly.</p>
</div>
<div class="hiw-feature">
<div class="hiw-feature-icon">