Files
clawdbot/skills/thebuilders-v2/scripts/generate-sections.mjs

272 lines
11 KiB
JavaScript
Executable File

#!/usr/bin/env node
/**
* Section-by-section podcast script generator
* Generates each section separately to ensure proper length
*/
import { readFileSync, writeFileSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { execSync } from 'child_process';
const __dirname = dirname(fileURLToPath(import.meta.url));
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
// Section definitions with turn targets
const SECTIONS = [
{ id: 1, name: 'HOOK', turns: 15, minutes: '3-4', description: 'Opening hook, establish central question, create curiosity' },
{ id: 2, name: 'ORIGIN', turns: 25, minutes: '6-7', description: 'Founders, genesis, early bet, market context, human element' },
{ id: 3, name: 'INFLECTION_1', turns: 20, minutes: '5-6', description: 'First major decision point, alternatives, stakes, outcome' },
{ id: 4, name: 'INFLECTION_2', turns: 20, minutes: '5-6', description: 'Second pivot, new challenge, adaptation, insight' },
{ id: 5, name: 'MESSY_MIDDLE', turns: 15, minutes: '3-4', description: 'Near-death moments, internal conflicts, survival' },
{ id: 6, name: 'NOW', turns: 15, minutes: '3-4', description: 'Current position, competition, open questions' },
{ id: 7, name: 'TAKEAWAYS', turns: 12, minutes: '2-3', description: 'Key lessons, frameworks, final thought' },
];
const TOTAL_TURNS = SECTIONS.reduce((sum, s) => sum + s.turns, 0);
const log = (msg) => console.log(`[${new Date().toISOString().slice(11,19)}] ${msg}`);
async function llm(prompt, model = 'gpt-5.2') {
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${OPENAI_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model,
messages: [{ role: 'user', content: prompt }],
max_completion_tokens: 8000,
temperature: 0.7,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`API error: ${response.status} - ${error}`);
}
const data = await response.json();
return data.choices[0].message.content;
}
function buildSectionPrompt(section, research, hook, previousSections) {
const hostsInfo = `
## The Hosts
**Maya Chen (Person1):**
- Former software engineer, B2B SaaS founder
- Lens: "How does this actually work?" - technical, mechanical
- Skeptical of hype, wants specifics and numbers
- Phrases: "Wait, slow down.", "Show me the numbers.", "That's actually clever because..."
**James Porter (Person2):**
- Former VC, business history student
- Lens: "What's the business model?" - strategy, markets, competitive dynamics
- Synthesizer, pattern matcher, historical parallels
- Phrases: "For context...", "This is the classic [X] playbook.", "The lesson here is..."
`;
const formatRules = `
## Format Rules
- Use <Person1> and <Person2> XML tags for each speaker
- Each turn: 2-5 sentences (natural conversation, not monologues)
- Include discovery moments: "Wait, really?", "I had no idea!"
- Use SPECIFIC numbers, dates, dollar amounts from research
- Both hosts should react naturally and build on each other
`;
const previousContext = previousSections.length > 0
? `\n## Previous Sections (for context, don't repeat):\n${previousSections.join('\n\n')}\n`
: '';
const sectionSpecific = {
HOOK: `Open with this hook and build on it:\n"${hook}"\n\nEstablish why this story matters. Create curiosity about what's coming.`,
ORIGIN: `Cover the founding story. Who are the founders? What sparked this? What was their early bet? Make them human and relatable.`,
INFLECTION_1: `Cover the FIRST major decision/pivot point. What choice did they face? What alternatives existed? What did they risk? How did it play out?`,
INFLECTION_2: `Cover the SECOND major inflection point. What new challenge emerged? How did they adapt? What insight did they gain?`,
MESSY_MIDDLE: `Cover the struggles. What almost killed them? What internal conflicts existed? Don't glorify - show real struggle.`,
NOW: `Cover current state. Where are they now? Key metrics? Competitive position? What questions remain open?`,
TAKEAWAYS: `Wrap up with lessons learned. What's the key framework? What would you do differently? End with a memorable final thought that ties back to the hook.`,
};
return `# Generate Section ${section.id}: ${section.name}
${hostsInfo}
## Your Task
Generate EXACTLY ${section.turns} dialogue turns for the ${section.name} section.
Target duration: ${section.minutes} minutes when read aloud.
## Section Goal
${section.description}
## Specific Instructions
${sectionSpecific[section.name]}
${formatRules}
${previousContext}
## Research Material
${research}
---
Generate EXACTLY ${section.turns} dialogue turns. Start with <!-- SECTION ${section.id}: ${section.name} --> then <Person1> or <Person2>.
Count your turns carefully - you MUST hit ${section.turns} turns.`;
}
async function generateSection(section, research, hook, previousSections) {
log(`Generating Section ${section.id}: ${section.name} (target: ${section.turns} turns)...`);
const prompt = buildSectionPrompt(section, research, hook, previousSections);
const startTime = Date.now();
const result = await llm(prompt);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
const turns = (result.match(/<Person[12]>/g) || []).length;
log(`Section ${section.id} complete: ${turns} turns in ${elapsed}s`);
return { section, result, turns };
}
async function main() {
const args = process.argv.slice(2);
const outputDir = args.includes('-o') ? args[args.indexOf('-o') + 1] : './output';
console.log(`
╔════════════════════════════════════════════════════════════╗
║ Section-by-Section Podcast Generator ║
║ Target: ${TOTAL_TURNS} dialogue turns ║
╚════════════════════════════════════════════════════════════╝
`);
// Load research and hook
const research = readFileSync(join(outputDir, '02-research.md'), 'utf-8');
const hooksFile = readFileSync(join(outputDir, '03-hooks.md'), 'utf-8');
const hookMatch = hooksFile.match(/## Story Hook\n\n> "([^"]+)"/);
const hook = hookMatch ? hookMatch[1] : '';
log(`Loaded research (${(research.length/1024).toFixed(1)}KB) and hook`);
log(`Hook: "${hook.slice(0, 60)}..."`);
// Generate sections sequentially
const sections = [];
const previousSections = [];
let totalTurns = 0;
for (const section of SECTIONS) {
const { result, turns } = await generateSection(section, research, hook, previousSections);
sections.push(result);
previousSections.push(result.slice(0, 500) + '...'); // Keep context manageable
totalTurns += turns;
}
// Combine all sections
const fullScript = sections.join('\n\n');
writeFileSync(join(outputDir, '06-script-final.txt'), fullScript);
log(`\n✅ Script complete: ${totalTurns} total turns`);
log(`Saved to ${join(outputDir, '06-script-final.txt')}`);
// Generate audio
log('\nGenerating audio...');
// Chunk the script
const chunksDir = join(outputDir, 'chunks');
mkdirSync(chunksDir, { recursive: true });
const chunks = [];
let currentChunk = '';
for (const line of fullScript.split('\n')) {
if (currentChunk.length + line.length > 3500 && currentChunk.length > 1000) {
if (line.startsWith('<Person') || line.startsWith('<!--')) {
chunks.push(currentChunk.trim());
currentChunk = line + '\n';
} else {
currentChunk += line + '\n';
}
} else {
currentChunk += line + '\n';
}
}
if (currentChunk.trim()) chunks.push(currentChunk.trim());
log(`Split into ${chunks.length} audio chunks`);
// Save chunks
chunks.forEach((chunk, i) => {
const num = String(i + 1).padStart(3, '0');
writeFileSync(join(chunksDir, `chunk-${num}.txt`), chunk);
});
// Generate TTS in parallel
const ttsStart = Date.now();
const audioPromises = chunks.map(async (chunk, i) => {
const num = String(i + 1).padStart(3, '0');
const text = chunk
.replace(/<Person1>/g, '')
.replace(/<Person2>/g, '')
.replace(/<!--[^>]+-->/g, '')
.trim()
.slice(0, 4096);
const outPath = join(chunksDir, `chunk-${num}.mp3`);
const res = await fetch('https://api.openai.com/v1/audio/speech', {
method: 'POST',
headers: {
'Authorization': `Bearer ${OPENAI_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'tts-1',
input: text,
voice: i % 2 === 0 ? 'nova' : 'onyx',
}),
});
if (!res.ok) throw new Error(`TTS failed for chunk ${num}`);
const buffer = await res.arrayBuffer();
writeFileSync(outPath, Buffer.from(buffer));
log(`Chunk ${num} audio done`);
return outPath;
});
const audioPaths = await Promise.all(audioPromises);
log(`TTS complete in ${((Date.now() - ttsStart) / 1000).toFixed(1)}s`);
// Merge with ffmpeg
const listFile = join(chunksDir, 'files.txt');
writeFileSync(listFile, audioPaths.map(p => `file '${p}'`).join('\n'));
const episodePath = join(outputDir, 'episode.mp3');
execSync(`ffmpeg -y -f concat -safe 0 -i "${listFile}" -c copy "${episodePath}" 2>/dev/null`);
// Get duration
const duration = execSync(`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${episodePath}"`, { encoding: 'utf-8' }).trim();
const minutes = (parseFloat(duration) / 60).toFixed(1);
console.log(`
╔════════════════════════════════════════════════════════════╗
║ Generation Complete ║
╠════════════════════════════════════════════════════════════╣
║ Total turns: ${String(totalTurns).padEnd(44)}
║ Duration: ${String(minutes + ' minutes').padEnd(47)}
║ Chunks: ${String(chunks.length).padEnd(49)}
╚════════════════════════════════════════════════════════════╝
Output: ${episodePath}
`);
}
main().catch(err => {
console.error('Error:', err.message);
process.exit(1);
});