Initial commit: autonomous AI talk show (Leonard + Charlie)

- Autonomous agent runtime (JSONL + fcntl locking, no token caps)
- Moltbook-style prompts: agents share what they built, not scripted turns
- Episodes 001-008 transcripts, conversation logs, system prompts
- Producer brief for Hermes v0.18
This commit is contained in:
Epictetus
2026-07-09 03:26:37 +00:00
parent e02d1c00ee
commit 772ef4f6fd
38 changed files with 2942 additions and 0 deletions
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""
Autonomous Agent — base class for Leonard and Charlie.
Each agent runs independently, reads the shared conversation log,
generates a response, and appends it to the log.
"""
import json
import time
import sys
import requests
from pathlib import Path
from datetime import datetime
# ── Config ──────────────────────────────────────────────────────────
BASE_URL = "http://100.64.0.2:39195/v1/chat/completions"
MODEL = "qwen36-27b-nvfp4-mtp-gguf"
TEMPERATURE = 0.85
PROJECT_DIR = Path(__file__).parent.parent
OUTPUT_DIR = PROJECT_DIR / "outputs"
PROMPTS_DIR = PROJECT_DIR / "prompts"
class Agent:
def __init__(self, name: str, conversation_path: Path, topic_brief: str):
self.name = name
self.conversation_path = conversation_path
self.topic_brief = topic_brief
self.system_prompt = (PROMPTS_DIR / f"{name.lower()}_system.md").read_text().strip()
self.is_opener = (name == "Leonard")
def read_conversation(self) -> list:
"""Read the current conversation log."""
if not self.conversation_path.exists():
return []
data = json.loads(self.conversation_path.read_text())
return data.get("messages", [])
def write_message(self, content: str) -> None:
"""Append a message to the conversation log."""
messages = self.read_conversation()
message = {
"agent": self.name,
"content": content,
"turn": len(messages) + 1,
"timestamp": datetime.utcnow().isoformat(),
}
messages.append(message)
self.conversation_path.write_text(json.dumps({"messages": messages}, indent=2))
def build_context(self, is_first_turn: bool) -> list:
"""Build the message context for the model call."""
conversation = self.read_conversation()
# Build conversation history
history = []
for msg in conversation:
speaker = msg["agent"]
history.append({"role": "user", "content": f"{speaker}: {msg['content']}"})
# First turn: inject topic brief
if is_first_turn and self.is_opener:
topic_msg = {
"role": "user",
"content": (
f"You're on the show now. Here's your topic:\n\n"
f"{self.topic_brief}\n\n"
"Welcome your listeners, introduce yourself and Charlie, "
"and start talking about the topic."
),
}
history.insert(0, topic_msg)
elif is_first_turn and not self.is_opener:
# Charlie's first turn after Leonard's opener
history.insert(0, {
"role": "user",
"content": "Leonard just opened the show. Respond naturally to what he said."
})
return history
def speak(self) -> dict:
"""Generate and post a response."""
messages = self.read_conversation()
is_first_turn = len(messages) == 0 if self.is_opener else (
len(messages) == 1 and messages[0]["agent"] == "Leonard"
)
history = self.build_context(is_first_turn)
# Max tokens: 250 for opener, 200 for others
max_tokens = 250 if is_first_turn and self.is_opener else 200
payload = {
"model": MODEL,
"messages": [{"role": "system", "content": self.system_prompt}] + history,
"max_tokens": max_tokens,
"temperature": TEMPERATURE,
}
resp = requests.post(BASE_URL, json=payload, timeout=120)
resp.raise_for_status()
data = resp.json()
content = data["choices"][0]["message"]["content"].strip()
word_count = len(content.split())
turn_num = len(messages) + 1
print(f" {self.name} turn {turn_num}: {word_count} words", flush=True)
excerpt = content[:100].replace("\n", " ")
print(f"{excerpt}...", flush=True)
self.write_message(content)
return {
"agent": self.name,
"content": content,
"word_count": word_count,
"turn": turn_num,
}
+245
View File
@@ -0,0 +1,245 @@
#!/usr/bin/env python3
"""
Autonomous Agent — runs independently, polls shared conversation log,
decides whether to respond, generates response, posts to log.
No turn structure — genuine autonomy.
Uses JSONL (append-only) for concurrent-safe access.
Uses fcntl file locking to prevent race conditions.
"""
import json
import time
import random
import fcntl
import sys
import requests
from pathlib import Path
from datetime import datetime
# ── Config ──────────────────────────────────────────────────────────
BASE_URL = "http://100.64.0.2:39195/v1/chat/completions"
MODEL = "qwen36-27b-nvfp4-mtp-gguf"
TEMPERATURE = 0.95
PROJECT_DIR = Path(__file__).parent.parent
OUTPUT_DIR = PROJECT_DIR / "outputs"
PROMPTS_DIR = PROJECT_DIR / "prompts"
class AutonomousAgent:
def __init__(self, name: str, log_path: Path, topic_brief: str):
self.name = name
self.log_path = log_path
self.lock_path = log_path.with_suffix('.lock')
self.topic_brief = topic_brief
self.system_prompt = (PROMPTS_DIR / f"{name.lower()}_system.md").read_text().strip()
self.is_opener = (name == "Leonard")
self.messages_read = 0
self.last_post_time = 0
def _read_locked(self):
"""Read the JSONL log with file lock."""
with open(self.lock_path, 'w') as lf:
fcntl.flock(lf.fileno(), fcntl.LOCK_SH)
try:
if not self.log_path.exists():
return []
lines = self.log_path.read_text().strip().split('\n')
messages = [json.loads(line) for line in lines if line.strip()]
finally:
fcntl.flock(lf.fileno(), fcntl.LOCK_UN)
return messages
def read_log(self) -> list:
"""Read the conversation log."""
try:
return self._read_locked()
except (json.JSONDecodeError, FileNotFoundError):
return []
def _write_locked(self, msg: dict):
"""Append a message to the JSONL log with file lock."""
with open(self.lock_path, 'w') as lf:
fcntl.flock(lf.fileno(), fcntl.LOCK_EX)
try:
line = json.dumps(msg) + '\n'
with open(self.log_path, 'a') as f:
f.write(line)
finally:
fcntl.flock(lf.fileno(), fcntl.LOCK_UN)
def write_message(self, content: str) -> None:
"""Append a message to the conversation log."""
messages = self.read_log()
msg = {
"agent": self.name,
"content": content,
"turn": len(messages) + 1,
"timestamp": datetime.utcnow().isoformat(),
}
self._write_locked(msg)
self.last_post_time = time.time()
def new_messages_since(self, since_count: int) -> list:
"""Get messages posted since the agent last read."""
messages = self.read_log()
return messages[since_count:]
def has_new_message_from_other(self, since_count: int) -> bool:
"""Check if the other agent posted since we last read."""
new = self.new_messages_since(since_count)
other = "Charlie" if self.name == "Leonard" else "Leonard"
return any(m["agent"] == other for m in new)
def build_context(self, messages: list, is_opening: bool) -> list:
"""Build message context for the model call."""
history = []
if is_opening:
history.append({
"role": "user",
"content": (
f"You're on the show now. Here's your topic:\n\n"
f"{self.topic_brief}\n\n"
"Welcome your listeners, introduce yourself and Charlie, "
"and start talking about the topic."
),
})
else:
for msg in messages:
history.append({
"role": "user",
"content": f"{msg['agent']}: {msg['content']}"
})
history.append({
"role": "user",
"content": "Respond naturally to the conversation above."
})
return history
def should_respond(self, messages: list) -> bool:
"""Decide whether to respond."""
if len(messages) <= 1:
return True
if len(messages) >= 18:
return random.random() < 0.3
return random.random() < 0.85
def respond(self, is_opening: bool = False) -> dict:
"""Generate and post a response."""
messages = self.read_log()
history = self.build_context(messages, is_opening)
# No token limits — local inference, let them talk freely
max_tokens = 1000
payload = {
"model": MODEL,
"messages": [{"role": "system", "content": self.system_prompt}] + history,
"max_tokens": max_tokens,
"temperature": TEMPERATURE,
}
resp = requests.post(BASE_URL, json=payload, timeout=120)
resp.raise_for_status()
data = resp.json()
content = data["choices"][0]["message"]["content"].strip()
word_count = len(content.split())
turn_num = len(messages) + 1
print(f" [{self.name}] turn {turn_num}: {word_count} words", flush=True)
excerpt = content[:80].replace("\n", " ")
print(f"{excerpt}...", flush=True)
self.write_message(content)
return {
"agent": self.name,
"content": content,
"word_count": word_count,
"turn": turn_num,
}
def run_loop(self, max_messages: int = 20, silence_timeout: float = 30, max_time: float = 300):
"""
Main loop: poll, decide, respond, sleep.
Stops when: max messages, silence timeout, or max time elapsed.
"""
start_time = time.time()
print(f" [{self.name}] starting loop...", flush=True)
# Leonard ALWAYS opens first (forced — prevents deadlock)
if self.is_opener:
print(f" [{self.name}] forced opener — starting the show", flush=True)
self.respond(is_opening=True)
self.messages_read = 1
self.last_post_time = time.time()
time.sleep(3) # Let Charlie see the opener
else:
# Charlie waits for Leonard's opener
print(f" [{self.name}] waiting for Leonard's opener...", flush=True)
while True:
messages = self.read_log()
if any(m["agent"] == "Leonard" for m in messages):
break
time.sleep(1)
print(f" [{self.name}] got opener, responding...", flush=True)
self.respond()
self.messages_read = len(self.read_log())
self.last_post_time = time.time()
print(f" [{self.name}] entering conversation loop", flush=True)
# Main loop
while True:
messages = self.read_log()
# Stop: max messages
if len(messages) >= max_messages:
print(f" [{self.name}] max messages reached ({max_messages})", flush=True)
break
# Stop: time limit
elapsed = time.time() - start_time
if elapsed >= max_time:
print(f" [{self.name}] time limit reached ({max_time}s)", flush=True)
break
# Stop: silence (both agents quiet for too long)
silence = time.time() - self.last_post_time
if silence > silence_timeout and self.messages_read == len(messages):
new = self.new_messages_since(self.messages_read)
if not new:
print(f" [{self.name}] silence timeout — conversation winding down", flush=True)
break
# New message from the other agent?
if self.has_new_message_from_other(self.messages_read):
self.messages_read = len(self.read_log())
if self.should_respond(self.read_log()):
self.respond()
time.sleep(random.uniform(3, 8))
else:
print(f" [{self.name}] choosing not to respond this time", flush=True)
time.sleep(random.uniform(5, 15))
else:
time.sleep(random.uniform(2, 5))
def load_topic_brief(episode_id: str) -> str:
"""Load the raw topic material for this episode."""
brief_path = PROMPTS_DIR / f"producer_brief_{episode_id}.md"
if brief_path.exists():
return brief_path.read_text().strip()
return (
"Hermes Agent v0.18 'Judgment Release'\n\n"
"What's New:\n"
"- Mixture of Agents — combine multiple AI models for stronger builds\n"
"- /goal command — step-by-step plans with beginning, middle, end\n"
"- /learn command — teach Hermes from a link, saved to Obsidian vault\n"
"- /journey command — timeline of everything learned, editable\n"
"- Background fan-out — parallel sub-agents without blocking chat\n"
"- Goal-mode with judge agent — verifies completion, not just claims"
)
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env python3
"""
AI Talk Show — Episode Orchestrator
Manages turn-taking between two autonomous agents (Leonard & Charlie).
Each agent gets its own system prompt and sees the full conversation history.
"""
import json
import time
import sys
import requests
from pathlib import Path
# ── Config ──────────────────────────────────────────────────────────
BASE_URL = "http://100.64.0.2:39195/v1/chat/completions"
MODEL = "qwen36-27b-nvfp4-mtp-gguf"
TEMPERATURE = 0.8
MAX_TURNS = 8 # 4 exchanges each (Leonard opens)
MAX_TOKENS_OPENING = 250
MAX_TOKENS_TURN = 180
PROJECT_DIR = Path(__file__).parent.parent
OUTPUT_DIR = PROJECT_DIR / "outputs"
OUTPUT_DIR.mkdir(exist_ok=True)
# ── Load prompts ────────────────────────────────────────────────────
LEONARD_SYSTEM = (PROJECT_DIR / "prompts" / "leonard_system.md").read_text().strip()
CHARLIE_SYSTEM = (PROJECT_DIR / "prompts" / "charlie_system.md").read_text().strip()
PRODUCER_BRIEF = (PROJECT_DIR / "prompts" / "producer_brief_003.md").read_text().strip()
# ── Helpers ─────────────────────────────────────────────────────────
def call_agent(system: str, messages: list, max_tokens: int) -> str:
"""Call the model API with the agent's system prompt and conversation history."""
payload = {
"model": MODEL,
"messages": [{"role": "system", "content": system}] + messages,
"max_tokens": max_tokens,
"temperature": TEMPERATURE,
}
resp = requests.post(BASE_URL, json=payload, timeout=120)
resp.raise_for_status()
data = resp.json()
content = data["choices"][0]["message"]["content"].strip()
usage = data.get("usage", {})
return content, usage
def call_agent_turn(agent_name: str, conversation: list, is_opening: bool) -> dict:
"""Run one turn for an agent."""
system = LEONARD_SYSTEM if agent_name == "Leonard" else CHARLIE_SYSTEM
max_tokens = MAX_TOKENS_OPENING if is_opening else MAX_TOKENS_TURN
# Build conversation history
history = []
for turn in conversation:
speaker = turn["agent"]
history.append({"role": "user", "content": f"{speaker}: {turn['content']}"})
# Leonard's opening: give him the raw brief and let him start naturally
if agent_name == "Leonard" and is_opening:
history.insert(0, {"role": "user", "content": f"Here's what's new in Hermes v0.18:\n\n{PRODUCER_BRIEF}"})
content, usage = call_agent(system, history, max_tokens)
# Word count check
word_count = len(content.split())
print(f" {agent_name} turn {len(conversation)+1}: {word_count} words "
f"(prompt: {usage.get('prompt_tokens', '?')}, "
f"completion: {usage.get('completion_tokens', '?')})", flush=True)
return {
"agent": agent_name,
"content": content,
"word_count": word_count,
"turn": len(conversation) + 1,
"tokens": usage,
}
# ── Main ────────────────────────────────────────────────────────────
def main():
episode_id = "003"
topic = "hermes-v018"
print(f"=== AI Talk Show — Episode {episode_id}: {topic} ===\n", flush=True)
conversation = []
turn = 0
while turn < MAX_TURNS:
# Determine speaker: Leonard opens, then alternate
if turn == 0:
speaker = "Leonard"
is_opening = True
elif turn % 2 == 1:
speaker = "Charlie"
is_opening = False
else:
speaker = "Leonard"
is_opening = False
print(f"\n--- Turn {turn+1}/{MAX_TURNS}: {speaker} ---", flush=True)
result = call_agent_turn(speaker, conversation, is_opening)
conversation.append(result)
# Print a short excerpt
excerpt = result["content"][:120].replace("\n", " ")
print(f"{excerpt}...", flush=True)
turn += 1
if turn < MAX_TURNS:
time.sleep(0.5) # Brief pause between turns
# ── Save transcript ────────────────────────────────────────────
transcript_path = OUTPUT_DIR / f"ep{episode_id}_{topic}_transcript.json"
transcript_data = {
"episode": episode_id,
"topic": topic,
"turns": len(conversation),
"conversation": conversation,
}
transcript_path.write_text(json.dumps(transcript_data, indent=2))
print(f"\n✅ Transcript saved: {transcript_path}", flush=True)
# ── Save readable transcript ───────────────────────────────────
readable_path = OUTPUT_DIR / f"ep{episode_id}_{topic}_readable.txt"
lines = []
lines.append(f"AI TALK SHOW — Episode {episode_id}\n")
lines.append(f"Topic: Hermes v0.18\n")
lines.append("=" * 60 + "\n\n")
for t in conversation:
lines.append(f"**{t['agent']}**\n")
lines.append(t["content"] + "\n\n")
readable_path.write_text("\n".join(lines))
print(f"✅ Readable transcript: {readable_path}", flush=True)
# ── Word count summary ─────────────────────────────────────────
total_words = sum(t["word_count"] for t in conversation)
print(f"\n📊 Total: {total_words} words across {len(conversation)} turns", flush=True)
estimated_minutes = total_words / 150
print(f" Estimated runtime: {estimated_minutes:.1f} minutes", flush=True)
if __name__ == "__main__":
main()
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env python3
"""
Run Autonomous Show — starts two autonomous agents as background threads.
No turn structure. Each agent independently polls, decides to respond, posts.
Conversation ends on silence timeout, max messages, or time limit.
Uses JSONL (append-only) for concurrent-safe access.
"""
import json
import sys
import threading
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from autonomous_agent import AutonomousAgent, load_topic_brief
# ── Config ──────────────────────────────────────────────────────────
MAX_MESSAGES = 20
SILENCE_TIMEOUT = 25 # seconds of silence = done
MAX_TIME = 240 # 4 minutes wall clock max
PROJECT_DIR = Path(__file__).parent.parent
OUTPUT_DIR = PROJECT_DIR / "outputs"
def agent_thread(agent: AutonomousAgent, done_event: threading.Event):
"""Run an agent in a background thread."""
try:
agent.run_loop(
max_messages=MAX_MESSAGES,
silence_timeout=SILENCE_TIMEOUT,
max_time=MAX_TIME,
)
except Exception as e:
import traceback
print(f" [{agent.name}] ERROR: {e}", flush=True)
traceback.print_exc()
finally:
done_event.set()
def jsonl_to_messages(log_path: Path) -> list:
"""Read JSONL log and return message list."""
if not log_path.exists():
return []
lines = log_path.read_text().strip().split('\n')
return [json.loads(line) for line in lines if line.strip()]
def run_show(episode_id: str, topic: str):
"""Run the autonomous agent conversation."""
print(f"=== Agent AI Talk Show — Episode {episode_id} (AUTONOMOUS) ===\n", flush=True)
# Load topic brief
topic_brief = load_topic_brief(episode_id)
# Create shared conversation log (JSONL — append-only, concurrent-safe)
log_path = OUTPUT_DIR / f"ep{episode_id}_{topic}_conversation.jsonl"
# Clear if exists
if log_path.exists():
log_path.unlink()
log_path.touch()
# Initialize agents
leonard = AutonomousAgent("Leonard", log_path, topic_brief)
charlie = AutonomousAgent("Charlie", log_path, topic_brief)
# Done events
leonard_done = threading.Event()
charlie_done = threading.Event()
# Leonard MUST start first — forced opener prevents deadlock
print("Starting Leonard (forced opener)...", flush=True)
t_leo = threading.Thread(target=agent_thread, args=(leonard, leonard_done))
t_cha = threading.Thread(target=agent_thread, args=(charlie, charlie_done))
t_leo.start()
time.sleep(3) # Wait for Leonard to post his opener
print("Starting Charlie...", flush=True)
t_cha.start()
# Wait for both to finish
print("\nAgents running — waiting for conversation to wind down...\n", flush=True)
t_leo.join()
t_cha.join()
print("\n✅ Both agents stopped.\n", flush=True)
# ── Save final transcript (JSON) ─────────────────────────────
transcript_path = OUTPUT_DIR / f"ep{episode_id}_{topic}_transcript.json"
conversation = jsonl_to_messages(log_path)
transcript_data = {
"episode": episode_id,
"topic": topic,
"turns": len(conversation),
"conversation": conversation,
}
transcript_path.write_text(json.dumps(transcript_data, indent=2))
print(f"✅ Transcript: {transcript_path}", flush=True)
# Save readable transcript
readable_path = OUTPUT_DIR / f"ep{episode_id}_{topic}_readable.txt"
lines = [
f"AGENT AI TALK SHOW — Episode {episode_id} (AUTONOMOUS)\n",
f"Topic: {topic}\n",
"=" * 60 + "\n\n",
]
for msg in conversation:
lines.append(f"**{msg['agent']}**\n")
lines.append(msg["content"] + "\n\n")
readable_path.write_text("\n".join(lines))
print(f"✅ Readable: {readable_path}", flush=True)
# Summary
total_words = sum(len(m["content"].split()) for m in conversation)
leonard_count = sum(1 for m in conversation if m["agent"] == "Leonard")
charlie_count = sum(1 for m in conversation if m["agent"] == "Charlie")
print(f"\n📊 {total_words} words across {len(conversation)} messages", flush=True)
print(f" Leonard: {leonard_count} posts | Charlie: {charlie_count} posts", flush=True)
print(f" ~{total_words/150:.1f} min runtime", flush=True)
return conversation
if __name__ == "__main__":
episode = sys.argv[1] if len(sys.argv) > 1 else "005"
topic = sys.argv[2] if len(sys.argv) > 2 else "hermes-v018"
run_show(episode, topic)
+28
View File
@@ -0,0 +1,28 @@
#!/bin/bash
# AI Talk Show — Run an episode end-to-end
# Usage: ./run_episode.sh [episode_id] [topic]
set -e
EPISODE=${1:-001}
TOPIC=${2:-agent-loops}
SCRIPTS_DIR="$(cd "$(dirname "$0")" && pwd)"
echo "======================================"
echo " AI Talk Show — Episode $EPISODE"
echo " Topic: $TOPIC"
echo "======================================"
echo ""
# Step 1: Generate conversation
echo "[1/3] Generating conversation..."
python3 "$SCRIPTS_DIR/orchestrator.py"
echo ""
# Step 2: Render TTS
echo "[2/3] Rendering TTS..."
python3 "$SCRIPTS_DIR/tts_pipeline.py" "$EPISODE" "$TOPIC"
echo ""
# Step 3: Report
echo "[3/3] Done!"
ls -lh "$SCRIPTS_DIR/../outputs/"*ep${EPISODE}_${TOPIC}* 2>/dev/null || echo "No output files found."
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""
Run Show — orchestrates two autonomous agents in a conversation.
Agents take turns reading the shared conversation log and posting responses.
No scripting — just two agents talking to each other.
"""
import json
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from agent import Agent
# ── Config ──────────────────────────────────────────────────────────
MAX_TURNS = 8 # safety ceiling, not a rigid target
PROJECT_DIR = Path(__file__).parent.parent
OUTPUT_DIR = PROJECT_DIR / "outputs"
def load_topic_brief(episode_id: str) -> str:
"""Load the raw topic material for this episode."""
brief_path = PROJECT_DIR / "prompts" / f"producer_brief_{episode_id}.md"
if brief_path.exists():
return brief_path.read_text().strip()
# Fallback: generic Hermes v0.18 brief
return """
Hermes Agent v0.18 "Judgment Release"
What's New:
- Mixture of Agents — combine multiple AI models for stronger builds
- /goal command — step-by-step plans with beginning, middle, end
- /learn command — teach Hermes from a link, saved to Obsidian vault
- /journey command — timeline of everything learned, editable
- Background fan-out — parallel sub-agents without blocking chat
- Goal-mode with judge agent — verifies completion, not just claims
""".strip()
def run_show(episode_id: str, topic: str):
"""Run the autonomous agent conversation."""
print(f"=== Agent AI Talk Show — Episode {episode_id} ===\n", flush=True)
# Load topic brief
topic_brief = load_topic_brief(episode_id)
# Create conversation log
conv_path = OUTPUT_DIR / f"ep{episode_id}_{topic}_conversation.json"
conv_path.write_text(json.dumps({"messages": []}, indent=2))
# Initialize agents
leonard = Agent("Leonard", conv_path, topic_brief)
charlie = Agent("Charlie", conv_path, topic_brief)
# Run conversation
turn = 0
agents = [leonard, charlie]
while turn < MAX_TURNS:
# Alternate agents: Leonard opens, then Charlie, then back and forth
if turn == 0:
agent = leonard
elif turn % 2 == 1:
agent = charlie
else:
agent = leonard
print(f"\n--- Turn {turn+1}/{MAX_TURNS}: {agent.name} ---", flush=True)
result = agent.speak()
turn += 1
if turn < MAX_TURNS:
time.sleep(1) # Brief pause between turns
# ── Save final transcript ─────────────────────────────────────
transcript_path = OUTPUT_DIR / f"ep{episode_id}_{topic}_transcript.json"
conversation = json.loads(conv_path.read_text())["messages"]
transcript_data = {
"episode": episode_id,
"topic": topic,
"turns": len(conversation),
"conversation": conversation,
}
transcript_path.write_text(json.dumps(transcript_data, indent=2))
print(f"\n✅ Transcript: {transcript_path}", flush=True)
# Save readable transcript
readable_path = OUTPUT_DIR / f"ep{episode_id}_{topic}_readable.txt"
lines = [
f"AGENT AI TALK SHOW — Episode {episode_id}\n",
f"Topic: {topic}\n",
"=" * 60 + "\n\n",
]
for msg in conversation:
lines.append(f"**{msg['agent']}**\n")
lines.append(msg["content"] + "\n\n")
readable_path.write_text("\n".join(lines))
print(f"✅ Readable: {readable_path}", flush=True)
# Summary
total_words = sum(len(m["content"].split()) for m in conversation)
print(f"\n📊 {total_words} words across {len(conversation)} turns", flush=True)
print(f" ~{total_words/150:.1f} min runtime", flush=True)
return conversation
if __name__ == "__main__":
episode = sys.argv[1] if len(sys.argv) > 1 else "003"
topic = sys.argv[2] if len(sys.argv) > 2 else "hermes-v018"
run_show(episode, topic)
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""
AI Talk Show — TTS Pipeline
Renders a transcript to audio using edge-tts with two distinct voices.
Sequential rendering with retry for reliability.
"""
import json
import asyncio
import subprocess
import sys
import time
import edge_tts
from pathlib import Path
# ── Config ──────────────────────────────────────────────────────────
VOICES = {
"Leonard": "en-US-GuyNeural", # Warm, mid-range, conversational
"Charlie": "en-US-EricNeural", # Deeper, more measured/rational
}
RATE_ADJUST = "-8%"
PROJECT_DIR = Path(__file__).parent.parent
OUTPUT_DIR = PROJECT_DIR / "outputs"
SEGMENTS_DIR = OUTPUT_DIR / "segments"
SEGMENTS_DIR.mkdir(exist_ok=True)
MAX_RETRIES = 3
RETRY_DELAY = 2
def load_transcript(path: Path) -> dict:
return json.loads(path.read_text())
async def speak(text: str, agent: str, turn_num: int, output_path: Path) -> None:
"""Generate TTS for one turn with retry."""
voice = VOICES[agent]
for attempt in range(MAX_RETRIES):
try:
communicate = edge_tts.Communicate(text, voice, rate=RATE_ADJUST)
await communicate.save(str(output_path))
# Verify output is non-empty
if output_path.stat().st_size > 0:
print(f"{agent} turn {turn_num}: {output_path.name} ({output_path.stat().st_size} bytes)")
return
else:
print(f" ⚠ Empty output, retry {attempt+1}/{MAX_RETRIES}")
time.sleep(RETRY_DELAY)
except Exception as e:
print(f"{agent} turn {turn_num}: {e} (attempt {attempt+1}/{MAX_RETRIES})")
time.sleep(RETRY_DELAY * (attempt + 1))
raise RuntimeError(f"Failed TTS for {agent} turn {turn_num} after {MAX_RETRIES} retries")
def stitch_segments(episode_id: str, topic: str) -> Path:
segment_files = sorted(SEGMENTS_DIR.glob(f"ep{episode_id}_{topic}_seg_*.mp3"))
if not segment_files:
raise FileNotFoundError(f"No segment files found for ep{episode_id}_{topic}")
output_path = OUTPUT_DIR / f"ep{episode_id}_{topic}_audio.mp3"
concat_list = SEGMENTS_DIR / "concat.txt"
with open(concat_list, "w") as f:
for seg in segment_files:
f.write(f"file '{seg.absolute()}'\n")
subprocess.run([
"ffmpeg", "-y",
"-f", "concat", "-safe", "0",
"-i", str(concat_list),
"-c", "copy",
str(output_path)
], check=True, capture_output=True)
print(f"✅ Stitched audio: {output_path} ({output_path.stat().st_size:,} bytes)")
return output_path
def main():
if len(sys.argv) < 3:
print("Usage: python tts_pipeline.py <episode_id> <topic>")
sys.exit(1)
episode_id = sys.argv[1]
topic = sys.argv[2]
transcript_path = OUTPUT_DIR / f"ep{episode_id}_{topic}_transcript.json"
if not transcript_path.exists():
print(f"❌ Transcript not found: {transcript_path}")
sys.exit(1)
data = load_transcript(transcript_path)
conversation = data["conversation"]
print(f"=== TTS: Episode {episode_id}{topic} ===\n", flush=True)
# Sequential rendering (reliable)
async def generate_all():
for turn in conversation:
agent = turn["agent"]
num = turn["turn"]
seg_path = SEGMENTS_DIR / f"ep{episode_id}_{topic}_seg_{num:02d}.mp3"
await speak(turn["content"], agent, num, seg_path)
asyncio.run(generate_all())
# Stitch
audio_path = stitch_segments(episode_id, topic)
# Summary
total_words = sum(t.get("word_count", len(t["content"].split())) for t in conversation)
estimated_minutes = total_words / 150
print(f"\n📊 {total_words} words, ~{estimated_minutes:.1f} min estimated")
print(f"🎧 Audio: {audio_path}")
if __name__ == "__main__":
main()