← back to Rentv Tour
vo-gen.mjs
46 lines
import fs from 'fs';
import { execSync } from 'child_process';
// 3-act VO in Steve's cloned voice. Generate each act separately so generate.mjs can pace each
// act's frames exactly to its narration length, then concat into one continuous track for muxing.
const KEY = (fs.readFileSync('/Users/macstudio3/Projects/secrets-manager/.env', 'utf8').match(/ELEVENLABS_API_KEY=(.+)/) || [])[1]?.trim();
if (!KEY) { console.error('NO ELEVENLABS_API_KEY'); process.exit(1); }
const VOICE = 'Xa9qV4wNbvSkdUWsYLzq'; // Steve's cloned voice (same as the original walkthrough)
const MODEL = 'eleven_turbo_v2_5';
const ACTS = {
growth: "This is RENTV — and this is the growth play. A trusted name in commercial real estate news, under-leveraged for too long. The strategy is simple: build the owned audience first, then ride the deal and conference calendar. You're not just a website — you're a media company. Every page, graded, with the fix.",
front: "Here's the front end — the public commercial real estate news platform. The home wire, the intelligence hub, search, rates and capital markets, sector deep-dives, deal flow by metro, the toolkit, the directory, and the RENTV brief. Everything a CRE audience needs, in one place.",
admin: "And behind it — the admin backend, internal intelligence, admin only. The backend hub, the broker and owner desk, audience and C-R-M, ten admin dashboards, the site versions, consulting, and the video library. One unified app — user and admin. RENTV, ready for 2026.",
};
const durations = {};
let totalChars = 0;
for (const [id, text] of Object.entries(ACTS)) {
totalChars += text.length;
const res = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${VOICE}?output_format=mp3_44100_128`, {
method: 'POST',
headers: { 'xi-api-key': KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({ text, model_id: MODEL, voice_settings: { stability: 0.5, similarity_boost: 0.85, style: 0.1, use_speaker_boost: true } }),
});
if (!res.ok) { console.error(`TTS ${id} failed: ${res.status} ${await res.text()}`); process.exit(1); }
const buf = Buffer.from(await res.arrayBuffer());
fs.writeFileSync(`/tmp/vo-${id}.mp3`, buf);
execSync(`ffmpeg -v error -y -i /tmp/vo-${id}.mp3 -c:a aac -b:a 160k videos/site-tour/assets/vo-${id}.m4a`);
const d = parseFloat(execSync(`ffprobe -v error -show_entries format=duration -of csv=p=0 videos/site-tour/assets/vo-${id}.m4a`).toString().trim());
durations[id] = d;
console.log(` ${id}: ${d.toFixed(2)}s (${text.length} chars)`);
}
// concat the 3 acts (with a 2.5s lead silence for the title card) into one continuous VO
execSync(`ffmpeg -v error -y -f lavfi -t 2.5 -i anullsrc=r=44100:cl=stereo -c:a aac -b:a 160k /tmp/vo-lead.m4a`);
fs.writeFileSync('/tmp/vo-concat.txt', ["/tmp/vo-lead.m4a","videos/site-tour/assets/vo-growth.m4a","videos/site-tour/assets/vo-front.m4a","videos/site-tour/assets/vo-admin.m4a"].map(f=>`file '${f.startsWith('/')?f:process.cwd()+'/'+f}'`).join('\n'));
execSync(`ffmpeg -v error -y -f concat -safe 0 -i /tmp/vo-concat.txt -c:a aac -b:a 160k videos/site-tour/assets/rentv-vo-3act.m4a`);
const totalD = parseFloat(execSync(`ffprobe -v error -show_entries format=duration -of csv=p=0 videos/site-tour/assets/rentv-vo-3act.m4a`).toString().trim());
// hand pacing to generate.mjs
fs.writeFileSync('videos/site-tour/assets/vo-durations.json', JSON.stringify({ lead: 2.5, ...durations, total: totalD }, null, 2));
const cost = (totalChars / 1000) * 0.30; // ~$0.30 / 1k chars (turbo)
console.log(`\nTOTAL VO ${totalD.toFixed(2)}s | ${totalChars} chars | est cost ~$${cost.toFixed(2)}`);
console.log('durations:', JSON.stringify(durations));