← back to Allnewsdaily
scripts/short/tts-elevenlabs.mjs
64 lines
#!/usr/bin/env node
// tts-elevenlabs.js — synthesize the daily-Short narration to data/short/vo.mp3 via ElevenLabs.
// Metered (~cents/run). Reads ELEVENLABS_API_KEY from ~/Projects/secrets-manager/.env.
// Voice defaults to Sarah (confident news-anchor tv voice); override with AND_TTS_VOICE.
// Model eleven_turbo_v2_5 = cheapest tier with strong quality — good for a news brief.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const HOME = process.env.HOME || '/Users/macstudio3';
const SECRETS = path.join(HOME, 'Projects/secrets-manager/.env');
function readEnv(file, key) {
try {
const line = fs.readFileSync(file, 'utf8').split('\n').find((l) => l.startsWith(key + '='));
return line ? line.slice(key.length + 1).trim() : null;
} catch { return null; }
}
const VOICE = process.env.AND_TTS_VOICE || 'Xa9qV4wNbvSkdUWsYLzq'; // Steve Abrams (cloned) — "use my voice"
const MODEL = process.env.AND_TTS_MODEL || 'eleven_multilingual_v2'; // richer/less-mechanical prosody than turbo
const RATE_PER_1K = Number(process.env.AND_TTS_RATE_PER_1K || 0.30);
export async function synthesize({ text, out }) {
const KEY = process.env.ELEVENLABS_API_KEY || readEnv(SECRETS, 'ELEVENLABS_API_KEY');
if (!KEY) throw new Error('ELEVENLABS_API_KEY not found (env or secrets-manager/.env)');
if (!text || !text.trim()) throw new Error('tts: empty narration text');
const chars = text.length;
const estCost = +((chars / 1000) * RATE_PER_1K).toFixed(4);
const url = `https://api.elevenlabs.io/v1/text-to-speech/${VOICE}?output_format=mp3_44100_128`;
const r = await fetch(url, {
method: 'POST',
headers: { 'xi-api-key': KEY, 'content-type': 'application/json', accept: 'audio/mpeg' },
body: JSON.stringify({
text,
model_id: MODEL,
voice_settings: { stability: 0.42, similarity_boost: 0.8, style: 0.35, use_speaker_boost: true },
}),
});
if (!r.ok) {
const body = await r.text().catch(() => '');
throw new Error(`ElevenLabs HTTP ${r.status}: ${body.slice(0, 300)}`);
}
const buf = Buffer.from(await r.arrayBuffer());
fs.mkdirSync(path.dirname(out), { recursive: true });
fs.writeFileSync(out, buf);
return { out, bytes: buf.length, chars, costUSD: estCost, voice: VOICE, model: MODEL };
}
// CLI: node tts-elevenlabs.js --text "…" --out data/short/vo.mp3 (or --file data/short/script.json)
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const arg = (n) => { const i = process.argv.indexOf(n); return i > -1 ? process.argv[i + 1] : null; };
const SKILL_DIR = path.dirname(fileURLToPath(import.meta.url));
const out = arg('--out') || path.join(SKILL_DIR, '../../data/short/vo.mp3');
let text = arg('--text');
const file = arg('--file');
if (!text && file) text = JSON.parse(fs.readFileSync(file, 'utf8')).narration;
if (!text) { console.error('need --text or --file <script.json>'); process.exit(1); }
synthesize({ text, out })
.then((r) => console.log(`✓ VO written: ${r.out} (${r.chars} chars, ${(r.bytes / 1024).toFixed(0)}KB, ~$${r.costUSD})`))
.catch((e) => { console.error('✗ TTS failed:', e.message); process.exit(1); });
}